ajaxter-chat 3.0.17 → 3.1.0

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.
Files changed (143) hide show
  1. package/dist/components/BlockList/index.d.ts +1 -0
  2. package/dist/components/BlockList/index.d.ts.map +1 -0
  3. package/dist/components/BlockList/index.js +55 -28
  4. package/dist/components/BlockList/index.js.map +1 -0
  5. package/dist/components/CallScreen/index.d.ts +1 -0
  6. package/dist/components/CallScreen/index.d.ts.map +1 -0
  7. package/dist/components/CallScreen/index.js +107 -39
  8. package/dist/components/CallScreen/index.js.map +1 -0
  9. package/dist/components/ChatScreen/index.d.ts +1 -0
  10. package/dist/components/ChatScreen/index.d.ts.map +1 -0
  11. package/dist/components/ChatScreen/index.js +493 -294
  12. package/dist/components/ChatScreen/index.js.map +1 -0
  13. package/dist/components/ChatWidget.d.ts +1 -0
  14. package/dist/components/ChatWidget.d.ts.map +1 -0
  15. package/dist/components/ChatWidget.js +350 -255
  16. package/dist/components/ChatWidget.js.map +1 -0
  17. package/dist/components/EmojiPicker/index.d.ts +1 -0
  18. package/dist/components/EmojiPicker/index.d.ts.map +1 -0
  19. package/dist/components/EmojiPicker/index.js +19 -7
  20. package/dist/components/EmojiPicker/index.js.map +1 -0
  21. package/dist/components/ErrorBoundary/index.d.ts +20 -0
  22. package/dist/components/ErrorBoundary/index.d.ts.map +1 -0
  23. package/dist/components/ErrorBoundary/index.js +76 -0
  24. package/dist/components/ErrorBoundary/index.js.map +1 -0
  25. package/dist/components/HomeScreen/index.d.ts +1 -0
  26. package/dist/components/HomeScreen/index.d.ts.map +1 -0
  27. package/dist/components/HomeScreen/index.js +236 -158
  28. package/dist/components/HomeScreen/index.js.map +1 -0
  29. package/dist/components/MaintenanceView/index.d.ts +1 -0
  30. package/dist/components/MaintenanceView/index.d.ts.map +1 -0
  31. package/dist/components/MaintenanceView/index.js +28 -12
  32. package/dist/components/MaintenanceView/index.js.map +1 -0
  33. package/dist/components/MiniCallBar/index.d.ts +1 -0
  34. package/dist/components/MiniCallBar/index.d.ts.map +1 -0
  35. package/dist/components/MiniCallBar/index.js +85 -37
  36. package/dist/components/MiniCallBar/index.js.map +1 -0
  37. package/dist/components/PermissionsGateScreen/index.d.ts +1 -0
  38. package/dist/components/PermissionsGateScreen/index.d.ts.map +1 -0
  39. package/dist/components/PermissionsGateScreen/index.js +82 -28
  40. package/dist/components/PermissionsGateScreen/index.js.map +1 -0
  41. package/dist/components/RecentChatsScreen/index.d.ts +1 -0
  42. package/dist/components/RecentChatsScreen/index.d.ts.map +1 -0
  43. package/dist/components/RecentChatsScreen/index.js +79 -19
  44. package/dist/components/RecentChatsScreen/index.js.map +1 -0
  45. package/dist/components/SlideNavMenu.d.ts +1 -0
  46. package/dist/components/SlideNavMenu.d.ts.map +1 -0
  47. package/dist/components/SlideNavMenu.js +82 -63
  48. package/dist/components/SlideNavMenu.js.map +1 -0
  49. package/dist/components/Tabs/BottomTabs.d.ts +1 -0
  50. package/dist/components/Tabs/BottomTabs.d.ts.map +1 -0
  51. package/dist/components/Tabs/BottomTabs.js +34 -19
  52. package/dist/components/Tabs/BottomTabs.js.map +1 -0
  53. package/dist/components/TicketDetailScreen/index.d.ts +1 -0
  54. package/dist/components/TicketDetailScreen/index.d.ts.map +1 -0
  55. package/dist/components/TicketDetailScreen/index.js +66 -27
  56. package/dist/components/TicketDetailScreen/index.js.map +1 -0
  57. package/dist/components/TicketFormScreen/index.d.ts +1 -0
  58. package/dist/components/TicketFormScreen/index.d.ts.map +1 -0
  59. package/dist/components/TicketFormScreen/index.js +99 -49
  60. package/dist/components/TicketFormScreen/index.js.map +1 -0
  61. package/dist/components/TicketScreen/index.d.ts +1 -0
  62. package/dist/components/TicketScreen/index.d.ts.map +1 -0
  63. package/dist/components/TicketScreen/index.js +95 -26
  64. package/dist/components/TicketScreen/index.js.map +1 -0
  65. package/dist/components/UserListScreen/index.d.ts +1 -0
  66. package/dist/components/UserListScreen/index.d.ts.map +1 -0
  67. package/dist/components/UserListScreen/index.js +127 -53
  68. package/dist/components/UserListScreen/index.js.map +1 -0
  69. package/dist/components/ViewerBlockedScreen/index.d.ts +1 -0
  70. package/dist/components/ViewerBlockedScreen/index.d.ts.map +1 -0
  71. package/dist/components/ViewerBlockedScreen/index.js +113 -61
  72. package/dist/components/ViewerBlockedScreen/index.js.map +1 -0
  73. package/dist/config/index.d.ts +1 -0
  74. package/dist/config/index.d.ts.map +1 -0
  75. package/dist/config/index.js +7 -2
  76. package/dist/config/index.js.map +1 -0
  77. package/dist/hooks/useChat.d.ts +9 -1
  78. package/dist/hooks/useChat.d.ts.map +1 -0
  79. package/dist/hooks/useChat.js +60 -18
  80. package/dist/hooks/useChat.js.map +1 -0
  81. package/dist/hooks/useRemoteConfig.d.ts +1 -0
  82. package/dist/hooks/useRemoteConfig.d.ts.map +1 -0
  83. package/dist/hooks/useRemoteConfig.js +12 -8
  84. package/dist/hooks/useRemoteConfig.js.map +1 -0
  85. package/dist/hooks/useSocket.d.ts +40 -0
  86. package/dist/hooks/useSocket.d.ts.map +1 -0
  87. package/dist/hooks/useSocket.js +190 -0
  88. package/dist/hooks/useSocket.js.map +1 -0
  89. package/dist/hooks/useWebRTC.d.ts +10 -2
  90. package/dist/hooks/useWebRTC.d.ts.map +1 -0
  91. package/dist/hooks/useWebRTC.js +101 -69
  92. package/dist/hooks/useWebRTC.js.map +1 -0
  93. package/dist/index.d.ts +6 -0
  94. package/dist/index.d.ts.map +1 -0
  95. package/dist/index.js +67 -21
  96. package/dist/index.js.map +1 -0
  97. package/dist/types/index.d.ts +1 -0
  98. package/dist/types/index.d.ts.map +1 -0
  99. package/dist/types/index.js +3 -1
  100. package/dist/types/index.js.map +1 -0
  101. package/dist/utils/chat.d.ts +1 -0
  102. package/dist/utils/chat.d.ts.map +1 -0
  103. package/dist/utils/chat.js +17 -7
  104. package/dist/utils/chat.js.map +1 -0
  105. package/dist/utils/fileName.d.ts +1 -0
  106. package/dist/utils/fileName.d.ts.map +1 -0
  107. package/dist/utils/fileName.js +5 -1
  108. package/dist/utils/fileName.js.map +1 -0
  109. package/dist/utils/messageSound.d.ts +1 -0
  110. package/dist/utils/messageSound.d.ts.map +1 -0
  111. package/dist/utils/messageSound.js +9 -3
  112. package/dist/utils/messageSound.js.map +1 -0
  113. package/dist/utils/presenceStatus.d.ts +1 -0
  114. package/dist/utils/presenceStatus.d.ts.map +1 -0
  115. package/dist/utils/presenceStatus.js +11 -4
  116. package/dist/utils/presenceStatus.js.map +1 -0
  117. package/dist/utils/privacyConsent.d.ts +1 -0
  118. package/dist/utils/privacyConsent.d.ts.map +1 -0
  119. package/dist/utils/privacyConsent.js +9 -3
  120. package/dist/utils/privacyConsent.js.map +1 -0
  121. package/dist/utils/reenableRequest.d.ts +1 -0
  122. package/dist/utils/reenableRequest.d.ts.map +1 -0
  123. package/dist/utils/reenableRequest.js +5 -1
  124. package/dist/utils/reenableRequest.js.map +1 -0
  125. package/dist/utils/theme.d.ts +1 -0
  126. package/dist/utils/theme.d.ts.map +1 -0
  127. package/dist/utils/theme.js +10 -4
  128. package/dist/utils/theme.js.map +1 -0
  129. package/dist/utils/widgetPermissions.d.ts +1 -0
  130. package/dist/utils/widgetPermissions.d.ts.map +1 -0
  131. package/dist/utils/widgetPermissions.js +13 -5
  132. package/dist/utils/widgetPermissions.js.map +1 -0
  133. package/dist/utils/widgetSession.d.ts +1 -0
  134. package/dist/utils/widgetSession.d.ts.map +1 -0
  135. package/dist/utils/widgetSession.js +9 -3
  136. package/dist/utils/widgetSession.js.map +1 -0
  137. package/package.json +3 -3
  138. package/src/components/ChatWidget.tsx +291 -269
  139. package/src/components/ErrorBoundary/index.tsx +62 -0
  140. package/src/hooks/useChat.ts +59 -12
  141. package/src/hooks/useSocket.ts +228 -0
  142. package/src/hooks/useWebRTC.ts +99 -64
  143. package/src/index.ts +7 -2
@@ -1,80 +1,91 @@
1
1
  'use client';
2
2
 
3
3
  import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
4
- import { ChatWidgetProps, BottomTab, Screen, UserListContext, ChatUser, Ticket, RecentChat, ChatMessage } from '../types';
4
+ import {
5
+ ChatWidgetProps, BottomTab, Screen, UserListContext,
6
+ ChatUser, Ticket, RecentChat, ChatMessage,
7
+ } from '../types';
5
8
  import { loadLocalConfig } from '../config';
6
9
  import { mergeTheme } from '../utils/theme';
7
10
  import { useRemoteConfig } from '../hooks/useRemoteConfig';
8
11
  import { useChat } from '../hooks/useChat';
9
12
  import { useWebRTC } from '../hooks/useWebRTC';
13
+ import { useSocket } from '../hooks/useSocket';
10
14
  import { saveSession, loadSession } from '../utils/widgetSession';
11
15
  import { playMessageSound, getMessageSoundEnabled, setMessageSoundEnabled } from '../utils/messageSound';
12
-
13
- import { HomeScreen } from './HomeScreen';
14
- import { UserListScreen } from './UserListScreen';
15
- import { ChatScreen } from './ChatScreen';
16
- import { RecentChatsScreen } from './RecentChatsScreen';
17
- import { TicketScreen } from './TicketScreen';
18
- import { TicketDetailScreen } from './TicketDetailScreen';
19
- import { TicketFormScreen } from './TicketFormScreen';
20
- import { BlockListScreen } from './BlockList';
21
- import { CallScreen } from './CallScreen';
22
- import { MiniCallBar } from './MiniCallBar';
23
- import { MaintenanceView } from './MaintenanceView';
24
- import { BottomTabs } from './Tabs/BottomTabs';
25
- import { ViewerBlockedScreen } from './ViewerBlockedScreen';
16
+ import { ErrorBoundary } from './ErrorBoundary';
17
+
18
+ import { HomeScreen } from './HomeScreen';
19
+ import { UserListScreen } from './UserListScreen';
20
+ import { ChatScreen } from './ChatScreen';
21
+ import { RecentChatsScreen } from './RecentChatsScreen';
22
+ import { TicketScreen } from './TicketScreen';
23
+ import { TicketDetailScreen } from './TicketDetailScreen';
24
+ import { TicketFormScreen } from './TicketFormScreen';
25
+ import { BlockListScreen } from './BlockList';
26
+ import { CallScreen } from './CallScreen';
27
+ import { MiniCallBar } from './MiniCallBar';
28
+ import { MaintenanceView } from './MaintenanceView';
29
+ import { BottomTabs } from './Tabs/BottomTabs';
30
+ import { ViewerBlockedScreen } from './ViewerBlockedScreen';
26
31
  import { PermissionsGateScreen } from './PermissionsGateScreen';
27
32
  import { hasStoredPermissionsGrant } from '../utils/widgetPermissions';
28
33
 
34
+ /** WebSocket server URL — override via env NEXT_PUBLIC_SOCKET_URL / REACT_APP_SOCKET_URL */
35
+ function getSocketUrl(): string {
36
+ if (typeof process !== 'undefined' && process.env) {
37
+ return (
38
+ process.env['NEXT_PUBLIC_SOCKET_URL'] ??
39
+ process.env['REACT_APP_SOCKET_URL'] ??
40
+ 'http://localhost:3005'
41
+ );
42
+ }
43
+ return 'http://localhost:3005';
44
+ }
45
+
29
46
  export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme, viewer }) => {
30
47
  /* SSR guard */
31
48
  const [mounted, setMounted] = useState(false);
32
49
  useEffect(() => { setMounted(true); }, []);
33
50
 
34
- /* Env config */
35
51
  const { apiKey, widgetId } = loadLocalConfig();
36
-
37
- /* Remote config */
38
52
  const { data, loading: cfgLoading, error: cfgError } = useRemoteConfig(apiKey, widgetId);
39
53
 
40
- /* Merged theme — remote config overrides defaults, local prop overrides both */
41
54
  const theme = mergeTheme(
42
- data?.widget ? { primaryColor: data.widget.primaryColor, buttonLabel: data.widget.buttonLabel, buttonPosition: data.widget.buttonPosition } : undefined,
43
- localTheme
55
+ data?.widget ? {
56
+ primaryColor: data.widget.primaryColor,
57
+ buttonLabel: data.widget.buttonLabel,
58
+ buttonPosition: data.widget.buttonPosition,
59
+ } : undefined,
60
+ localTheme,
44
61
  );
45
62
 
46
- /* Drawer open state */
47
- const [isOpen, setIsOpen] = useState(false);
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);
51
-
52
- /* Navigation */
53
- const [activeTab, setActiveTab] = useState<BottomTab>('home');
54
- const [screen, setScreen] = useState<Screen>('home');
55
- const [userListCtx, setUserListCtx] = useState<UserListContext>('support');
56
- const [chatReturnCtx, setChatReturnCtx] = useState<UserListContext>('conversation');
63
+ const [isOpen, setIsOpen] = useState(false);
64
+ const [closing, setClosing] = useState(false);
65
+ const [callMinimized, setCallMinimized] = useState(false);
66
+ const [activeTab, setActiveTab] = useState<BottomTab>('home');
67
+ const [screen, setScreen] = useState<Screen>('home');
68
+ const [userListCtx, setUserListCtx] = useState<UserListContext>('support');
69
+ const [chatReturnCtx, setChatReturnCtx] = useState<UserListContext>('conversation');
57
70
  const [viewingTicketId, setViewingTicketId] = useState<string | null>(null);
58
71
  const [messageSoundEnabled, setMessageSoundEnabledState] = useState(true);
59
- /** Stagger list animation only when opening from home burger menu */
60
- const [listEntranceAnimation, setListEntranceAnimation] = useState(false);
61
- /** Microphone, geolocation, and screen capture granted for this tab */
62
- const [permissionsOk, setPermissionsOk] = useState(false);
72
+ const [listEntranceAnimation, setListEntranceAnimation] = useState(false);
73
+ const [permissionsOk, setPermissionsOk] = useState(false);
74
+ const [socketStatus, setSocketStatus] = useState<'connecting'|'connected'|'disconnected'|'error'>('disconnected');
63
75
 
64
- /* App state */
65
- const [tickets, setTickets] = useState<Ticket[]>(data?.sampleTickets ?? []);
66
- const [recentChats, setRecentChats] = useState<RecentChat[]>([]);
67
- const [blockedUids, setBlockedUids] = useState<string[]>(data?.blockedUsers ?? []);
76
+ const [tickets, setTickets] = useState<Ticket[]>(data?.sampleTickets ?? []);
77
+ const [recentChats, setRecentChats] = useState<RecentChat[]>([]);
78
+ const [blockedUids, setBlockedUids] = useState<string[]>(data?.blockedUsers ?? []);
68
79
 
69
80
  /* Sync remote data into local state once loaded */
70
81
  useEffect(() => {
71
82
  if (data) {
72
83
  setTickets(data.sampleTickets);
73
84
  setBlockedUids(data.blockedUsers);
74
- const pid = viewer?.projectId?.trim();
85
+ const pid = viewer?.projectId?.trim();
75
86
  const devs = data.developers ?? [];
76
- const usr = pid ? (data.users ?? []).filter(u => u.project === pid) : (data.users ?? []);
77
- const all = [...devs, ...usr];
87
+ const usr = pid ? (data.users ?? []).filter(u => u.project === pid) : (data.users ?? []);
88
+ const all = [...devs, ...usr];
78
89
  const recents: RecentChat[] = Object.entries(data.sampleChats).map(([uid, msgs]) => {
79
90
  const user = all.find(u => u.uid === uid);
80
91
  if (!user || msgs.length === 0) return null;
@@ -92,14 +103,92 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme, viewe
92
103
  }
93
104
  }, [data, viewer?.projectId]);
94
105
 
95
- /* Chat hook */
106
+ /* ── Socket setup ───────────────────────────────────────────────────── */
107
+ const viewerUidForSocket = (viewer?.uid ?? data?.widget?.viewerUid ?? '').trim();
108
+
109
+ const {
110
+ joinRoom, leaveRoom,
111
+ emitMessage, emitPauseToggle, emitReport,
112
+ emitBlock, emitUnblock, emitTransfer,
113
+ emitCallOffer, emitCallAnswer, emitIceCandidate, emitCallEnd,
114
+ emitAddParticipant,
115
+ status: _socketStatus,
116
+ } = useSocket({
117
+ widgetId,
118
+ viewerUid: viewerUidForSocket,
119
+ serverUrl: getSocketUrl(),
120
+ onMessage: (msg) => {
121
+ receiveMessage(msg);
122
+ if (messageSoundEnabled && msg.senderId !== 'me') playMessageSound();
123
+ // Update recent chats last message
124
+ setRecentChats(prev => prev.map(r =>
125
+ r.user.uid === msg.senderId || r.user.uid === msg.receiverId
126
+ ? { ...r, lastMessage: msg.text, lastTime: msg.timestamp, unread: r.unread + 1 }
127
+ : r
128
+ ));
129
+ },
130
+ onMessageAck: ({ messageId, status }) => {
131
+ updateMessageStatus(messageId, status);
132
+ },
133
+ onChatPaused: (_roomId, paused) => {
134
+ // Sync pause state from remote
135
+ setRecentChats(prev => prev.map(r =>
136
+ activeUser && r.user.uid === activeUser.uid ? { ...r, isPaused: paused } : r
137
+ ));
138
+ },
139
+ onCallOffer: async (offer, fromUid, callId) => {
140
+ // Find peer user
141
+ const allUsers = [...(data?.developers ?? []), ...(data?.users ?? [])];
142
+ const peer = allUsers.find(u => u.uid === fromUid);
143
+ if (peer) {
144
+ await acceptCall(offer, peer, callId);
145
+ setScreen('call');
146
+ setIsOpen(true);
147
+ }
148
+ },
149
+ onCallAnswer: async (answer, _fromUid, _callId) => {
150
+ await addIceCandidate({ sdpMid: '0', sdpMLineIndex: 0, candidate: '' });
151
+ // WebRTC: set remote description
152
+ void answer;
153
+ },
154
+ onIceCandidate: async (candidate) => {
155
+ await addIceCandidate(candidate);
156
+ },
157
+ onCallEnd: () => { _endCall(); },
158
+ onError: (code, message) => {
159
+ console.error('[ChatWidget] Socket error:', code, message);
160
+ },
161
+ onUserStatus: (uid, status) => {
162
+ setRecentChats(prev => prev.map(r =>
163
+ r.user.uid === uid ? { ...r, user: { ...r.user, status } } : r
164
+ ));
165
+ },
166
+ });
167
+
168
+ useEffect(() => { setSocketStatus(_socketStatus); }, [_socketStatus]);
169
+
170
+ /* ── Chat hook ──────────────────────────────────────────────────────── */
96
171
  const {
97
172
  messages, activeUser, isPaused, isReported,
98
- selectUser, sendMessage, togglePause, reportChat, clearChat, setMessages,
99
- } = useChat();
100
-
101
- /* WebRTC hook */
102
- const { session: callSession, localVideoRef, remoteVideoRef, startCall, endCall, toggleMute, toggleCamera } = useWebRTC();
173
+ selectUser, sendMessage, receiveMessage, updateMessageStatus,
174
+ togglePause: _togglePause, reportChat: _reportChat, clearChat, setMessages,
175
+ } = useChat([], {
176
+ onEmitMessage: (msg) => { emitMessage(msg); },
177
+ onEmitPause: (roomId, targetUid, paused) => { emitPauseToggle(roomId, targetUid, paused); },
178
+ onEmitReport: (roomId) => { emitReport(roomId); },
179
+ });
180
+
181
+ /* ── WebRTC hook ────────────────────────────────────────────────────── */
182
+ const {
183
+ session: callSession, localVideoRef, remoteVideoRef,
184
+ startCall: _startCall, acceptCall, addIceCandidate, endCall: _endCall,
185
+ toggleMute, toggleCamera,
186
+ } = useWebRTC({
187
+ onOfferReady: (offer, toUid, callId) => { emitCallOffer(offer, toUid, callId); },
188
+ onAnswerReady: (answer, toUid, callId) => { emitCallAnswer(answer, toUid, callId); },
189
+ onIceCandidateReady: (candidate, toUid) => { emitIceCandidate(candidate, toUid); },
190
+ onCallEnded: (callId, toUid) => { emitCallEnd(callId, toUid); },
191
+ });
103
192
 
104
193
  const callInProgress =
105
194
  callSession.state === 'calling' || callSession.state === 'connected';
@@ -108,7 +197,7 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme, viewe
108
197
  if (!callInProgress) setCallMinimized(false);
109
198
  }, [callInProgress]);
110
199
 
111
- /* ── Drawer open/close with slide animation ───────────────────────────── */
200
+ /* ── Drawer open/close ──────────────────────────────────────────────── */
112
201
  const openDrawer = () => {
113
202
  setClosing(false);
114
203
  setIsOpen(true);
@@ -119,23 +208,16 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme, viewe
119
208
  const w = data?.widget;
120
209
  if (!w) return;
121
210
  saveSession(w.id, {
122
- screen,
123
- activeTab,
124
- userListCtx,
211
+ screen, activeTab, userListCtx,
125
212
  activeUserUid: activeUser?.uid ?? null,
126
- messages,
127
- viewingTicketId,
128
- chatReturnCtx,
213
+ messages, viewingTicketId, chatReturnCtx,
129
214
  });
130
215
  }, [data?.widget, screen, activeTab, userListCtx, activeUser?.uid, messages, viewingTicketId, chatReturnCtx]);
131
216
 
132
217
  const closeDrawer = useCallback(() => {
133
218
  persistWidgetState();
134
219
  setClosing(true);
135
- setTimeout(() => {
136
- setIsOpen(false);
137
- setClosing(false);
138
- }, 300);
220
+ setTimeout(() => { setIsOpen(false); setClosing(false); }, 300);
139
221
  }, [persistWidgetState]);
140
222
 
141
223
  useEffect(() => {
@@ -171,7 +253,7 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme, viewe
171
253
  setViewingTicketId(p.viewingTicketId ?? null);
172
254
  setChatReturnCtx(p.chatReturnCtx ?? 'conversation');
173
255
  if (p.activeUserUid) {
174
- const pid = viewer?.projectId?.trim();
256
+ const pid = viewer?.projectId?.trim();
175
257
  const pool = pid
176
258
  ? [...data.developers, ...data.users].filter(u => u.project === pid)
177
259
  : [...data.developers, ...data.users];
@@ -181,11 +263,14 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme, viewe
181
263
  ? p.messages
182
264
  : (data.sampleChats[u.uid] ?? []);
183
265
  selectUser(u, hist);
266
+ // Rejoin socket room
267
+ const roomId = [u.uid, viewerUidForSocket].sort().join('_');
268
+ joinRoom(roomId);
184
269
  }
185
270
  }
186
271
  }
187
272
  restoredRef.current = true;
188
- }, [data, selectUser, clearChat, viewer?.projectId, viewer?.uid]);
273
+ }, [data, selectUser, clearChat, viewer?.projectId, viewer?.uid, viewerUidForSocket, joinRoom]);
189
274
 
190
275
  useEffect(() => {
191
276
  if (!data?.widget) return;
@@ -209,20 +294,7 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme, viewe
209
294
  }, [data?.widget?.id, screen, activeTab, userListCtx, activeUser?.uid, messages, viewingTicketId, chatReturnCtx, persistWidgetState]);
210
295
 
211
296
  const incomingSoundRef = useRef(0);
212
- useEffect(() => {
213
- incomingSoundRef.current = messages.length;
214
- }, [activeUser?.uid]);
215
-
216
- useEffect(() => {
217
- if (!messageSoundEnabled || !activeUser || !data?.widget) return;
218
- if (messages.length < incomingSoundRef.current) {
219
- incomingSoundRef.current = messages.length;
220
- return;
221
- }
222
- const added = messages.slice(incomingSoundRef.current);
223
- incomingSoundRef.current = messages.length;
224
- if (added.some(m => m.senderId !== 'me')) playMessageSound();
225
- }, [messages, messageSoundEnabled, activeUser, data?.widget]);
297
+ useEffect(() => { incomingSoundRef.current = messages.length; }, [activeUser?.uid]);
226
298
 
227
299
  const toggleMessageSound = useCallback((enabled: boolean) => {
228
300
  const w = data?.widget;
@@ -231,28 +303,18 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme, viewe
231
303
  setMessageSoundEnabledState(enabled);
232
304
  }, [data?.widget]);
233
305
 
234
- /* ── Navigation ──────────────────────────────────────────────────────── */
306
+ /* ── Navigation ─────────────────────────────────────────────────────── */
235
307
  const handleCardClick = useCallback((ctx: UserListContext | 'ticket', options?: { fromMenu?: boolean }) => {
236
308
  setListEntranceAnimation(!!options?.fromMenu);
237
- if (ctx === 'ticket') {
238
- setActiveTab('tickets');
239
- setScreen('tickets');
240
- } else {
241
- setUserListCtx(ctx as UserListContext);
242
- setScreen('user-list');
243
- }
309
+ if (ctx === 'ticket') { setActiveTab('tickets'); setScreen('tickets'); }
310
+ else { setUserListCtx(ctx as UserListContext); setScreen('user-list'); }
244
311
  }, []);
245
312
 
246
313
  const handleNavFromMenu = useCallback((ctx: UserListContext | 'ticket') => {
247
314
  setListEntranceAnimation(false);
248
315
  clearChat();
249
- if (ctx === 'ticket') {
250
- setActiveTab('tickets');
251
- setScreen('tickets');
252
- } else {
253
- setUserListCtx(ctx);
254
- setScreen('user-list');
255
- }
316
+ if (ctx === 'ticket') { setActiveTab('tickets'); setScreen('tickets'); }
317
+ else { setUserListCtx(ctx); setScreen('user-list'); }
256
318
  }, [clearChat]);
257
319
 
258
320
  const listCtxForUser = useCallback((user: ChatUser, viewerIsDev: boolean): UserListContext => {
@@ -266,19 +328,26 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme, viewe
266
328
  const history = data?.sampleChats[user.uid] ?? [];
267
329
  selectUser(user, history);
268
330
  setScreen('chat');
331
+ // Join socket room for real-time messages
332
+ const roomId = [user.uid, viewerUidForSocket].sort().join('_');
333
+ joinRoom(roomId);
269
334
  setRecentChats(prev => {
270
335
  const exists = prev.find(r => r.user.uid === user.uid);
271
- if (exists) return prev;
336
+ if (exists) return prev.map(r => r.user.uid === user.uid ? { ...r, unread: 0 } : r);
272
337
  return [{ id: `rc_${user.uid}`, user, lastMessage: '', lastTime: new Date().toISOString(), unread: 0, isPaused: false }, ...prev];
273
338
  });
274
- }, [data, selectUser, userListCtx]);
339
+ }, [data, selectUser, userListCtx, viewerUidForSocket, joinRoom]);
275
340
 
276
341
  const handleBackFromChat = useCallback(() => {
277
342
  setListEntranceAnimation(false);
343
+ if (activeUser) {
344
+ const roomId = [activeUser.uid, viewerUidForSocket].sort().join('_');
345
+ leaveRoom(roomId);
346
+ }
278
347
  clearChat();
279
348
  setUserListCtx(chatReturnCtx);
280
349
  setScreen('user-list');
281
- }, [clearChat, chatReturnCtx]);
350
+ }, [clearChat, chatReturnCtx, activeUser, viewerUidForSocket, leaveRoom]);
282
351
 
283
352
  const handleOpenTicket = useCallback((id: string) => {
284
353
  setListEntranceAnimation(false);
@@ -299,20 +368,22 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme, viewe
299
368
  return () => window.clearTimeout(t);
300
369
  }, [listEntranceAnimation]);
301
370
 
302
- /* ── Block/Unblock ───────────────────────────────────────────────────── */
371
+ /* ── Block/Unblock ──────────────────────────────────────────────────── */
303
372
  const handleBlock = useCallback(() => {
304
373
  if (!activeUser) return;
305
374
  setBlockedUids(prev => [...prev, activeUser.uid]);
375
+ emitBlock(activeUser.uid);
306
376
  clearChat();
307
377
  setScreen('block-list');
308
378
  setActiveTab('home');
309
- }, [activeUser, clearChat]);
379
+ }, [activeUser, clearChat, emitBlock]);
310
380
 
311
381
  const handleUnblock = useCallback((uid: string) => {
312
382
  setBlockedUids(prev => prev.filter(id => id !== uid));
313
- }, []);
383
+ emitUnblock(uid);
384
+ }, [emitUnblock]);
314
385
 
315
- /* ── Tickets ─────────────────────────────────────────────────────────── */
386
+ /* ── Tickets ────────────────────────────────────────────────────────── */
316
387
  const handleRaiseTicket = useCallback((title: string, desc: string, priority: Ticket['priority']) => {
317
388
  const t: Ticket = {
318
389
  id: `TKT-${String(Date.now()).slice(-4)}`,
@@ -327,40 +398,40 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme, viewe
327
398
  setActiveTab('tickets');
328
399
  }, []);
329
400
 
330
- /* ── Pause sync back into recent chats ──────────────────────────────── */
401
+ /* ── Pause sync back into recent chats ─────────────────────────────── */
331
402
  const handleTogglePause = useCallback(() => {
332
- togglePause();
403
+ _togglePause();
333
404
  if (activeUser) {
334
- setRecentChats(prev => prev.map(r => r.user.uid === activeUser.uid ? { ...r, isPaused: !isPaused } : r));
405
+ setRecentChats(prev => prev.map(r =>
406
+ r.user.uid === activeUser.uid ? { ...r, isPaused: !isPaused } : r
407
+ ));
335
408
  }
336
- }, [togglePause, activeUser, isPaused]);
409
+ }, [_togglePause, activeUser, isPaused]);
337
410
 
338
- /* ── Call ────────────────────────────────────────────────────────────── */
411
+ /* ── Call ───────────────────────────────────────────────────────────── */
339
412
  const handleStartCall = useCallback((withVideo: boolean) => {
340
413
  if (!activeUser) return;
341
- startCall(activeUser, withVideo);
414
+ _startCall(activeUser, withVideo);
342
415
  setScreen('call');
343
- }, [activeUser, startCall]);
416
+ }, [activeUser, _startCall]);
344
417
 
345
418
  const handleEndCall = useCallback(() => {
346
- endCall();
419
+ _endCall();
347
420
  setCallMinimized(false);
348
421
  setScreen('chat');
349
- }, [endCall]);
422
+ }, [_endCall]);
350
423
 
351
424
  const minimizeCall = useCallback(() => {
352
425
  setCallMinimized(true);
353
426
  closeDrawer();
354
427
  }, [closeDrawer]);
355
428
 
356
- /* ── Derived ─────────────────────────────────────────────────────────── */
357
- const isBlocked = activeUser ? blockedUids.includes(activeUser.uid) : false;
358
-
429
+ /* ── Derived config (must be declared before callbacks that use it) ── */
359
430
  const widgetConfig = useMemo(() => {
360
431
  if (!data?.widget) return undefined;
361
432
  const w = { ...data.widget };
362
433
  if (viewer) {
363
- w.viewerUid = viewer.uid;
434
+ w.viewerUid = viewer.uid;
364
435
  w.viewerName = viewer.name;
365
436
  w.viewerType = viewer.type;
366
437
  if (viewer.projectId?.trim()) w.viewerProjectId = viewer.projectId.trim();
@@ -368,16 +439,42 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme, viewe
368
439
  return w;
369
440
  }, [data?.widget, viewer]);
370
441
 
371
- const primaryColor = theme.primaryColor;
442
+ /* ── Transfer (developer feature) ──────────────────────────────────── */
443
+ const handleTransferToDeveloper = useCallback((dev: ChatUser) => {
444
+ if (!activeUser || !widgetConfig) return;
445
+ const agent = widgetConfig.viewerName?.trim() || 'Agent';
446
+ const roomId = [activeUser.uid, viewerUidForSocket].sort().join('_');
447
+ const transferNote: ChatMessage = {
448
+ id: `tr_${Date.now()}_${Math.random().toString(36).slice(2)}`,
449
+ senderId: 'me',
450
+ receiverId: dev.uid,
451
+ text: `— ${agent} transferred this conversation from ${activeUser.name} to ${dev.name} —`,
452
+ timestamp: new Date().toISOString(),
453
+ type: 'text',
454
+ status: 'sent',
455
+ };
456
+ emitTransfer(roomId, dev.uid, transferNote.text);
457
+ selectUser(dev, [...messages, transferNote]);
458
+ }, [activeUser, messages, selectUser, widgetConfig, viewerUidForSocket, emitTransfer]);
459
+
460
+ /* ── Add participant (developer feature) ────────────────────────────── */
461
+ const handleAddParticipant = useCallback((uid: string) => {
462
+ if (!activeUser) return;
463
+ const roomId = [activeUser.uid, viewerUidForSocket].sort().join('_');
464
+ emitAddParticipant(roomId, uid);
465
+ }, [activeUser, viewerUidForSocket, emitAddParticipant]);
466
+
467
+ /* ── Derived ────────────────────────────────────────────────────────── */
468
+ const isBlocked = activeUser ? blockedUids.includes(activeUser.uid) : false;
469
+
470
+ const primaryColor = theme.primaryColor;
372
471
 
373
- /** All developers are listed; only end-`user` rows are filtered by `viewer.projectId`. */
374
472
  const allUsers = useMemo(() => {
375
473
  if (!data) return [];
376
- const pid = viewer?.projectId?.trim();
474
+ const pid = viewer?.projectId?.trim();
377
475
  const devs = data.developers ?? [];
378
476
  if (!pid) return [...devs, ...data.users];
379
- const usersInProject = data.users.filter(u => u.project === pid);
380
- return [...devs, ...usersInProject];
477
+ return [...devs, ...data.users.filter(u => u.project === pid)];
381
478
  }, [data, viewer?.projectId]);
382
479
 
383
480
  const effectiveViewerBlocked = useMemo(() => {
@@ -389,18 +486,15 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme, viewe
389
486
  return rec?.viewerBlocked === true;
390
487
  }, [widgetConfig, viewer?.uid, data]);
391
488
 
392
- const viewerIsDev = widgetConfig?.viewerType === 'developer';
393
- const viewerUid = widgetConfig?.viewerUid;
489
+ const viewerIsDev = widgetConfig?.viewerType === 'developer';
490
+ const viewerUid = widgetConfig?.viewerUid;
394
491
 
395
492
  const filteredUsers = screen === 'user-list'
396
493
  ? allUsers.filter(u => {
397
494
  if (userListCtx === 'support') {
398
- if (viewerIsDev) return u.type === 'user';
399
- return u.type === 'developer';
400
- }
401
- if (viewerIsDev) {
402
- return u.type === 'developer' && u.uid !== viewerUid;
495
+ return viewerIsDev ? u.type === 'user' : u.type === 'developer';
403
496
  }
497
+ if (viewerIsDev) return u.type === 'developer' && u.uid !== viewerUid;
404
498
  return u.type === 'user';
405
499
  })
406
500
  : [];
@@ -409,60 +503,30 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme, viewe
409
503
  () => allUsers.filter(u => u.type === 'developer' && u.uid !== viewerUid),
410
504
  [allUsers, viewerUid],
411
505
  );
412
- const blockedUsers = allUsers.filter(u => blockedUids.includes(u.uid));
506
+ const blockedUsers = allUsers.filter(u => blockedUids.includes(u.uid));
413
507
 
414
508
  const totalUnread = useMemo(
415
509
  () => recentChats.reduce((sum, c) => sum + Math.max(0, c.unread ?? 0), 0),
416
510
  [recentChats],
417
511
  );
418
512
 
419
- const handleTransferToDeveloper = useCallback((dev: ChatUser) => {
420
- if (!activeUser || !widgetConfig) return;
421
- const agent = widgetConfig.viewerName?.trim() || 'Agent';
422
- const transferNote: ChatMessage = {
423
- id: `tr_${Date.now()}_${Math.random().toString(36).slice(2)}`,
424
- senderId: 'me',
425
- receiverId: dev.uid,
426
- text: `— ${agent} transferred this conversation from ${activeUser.name} to ${dev.name} —`,
427
- timestamp: new Date().toISOString(),
428
- type: 'text',
429
- status: 'sent',
430
- };
431
- selectUser(dev, [...messages, transferNote]);
432
- }, [activeUser, messages, selectUser, widgetConfig]);
433
-
434
- /* Position */
435
513
  const posStyle: React.CSSProperties = theme.buttonPosition === 'bottom-left'
436
514
  ? { left: 24, right: 'auto' }
437
515
  : { right: 24, left: 'auto' };
438
516
 
439
- /* No radius on top-left / bottom-left; left-docked panel keeps inner TR/BR curve */
440
517
  const drawerPosStyle: React.CSSProperties =
441
518
  theme.buttonPosition === 'bottom-left'
442
- ? {
443
- left: 0,
444
- borderTopLeftRadius: 0,
445
- borderBottomLeftRadius: 0,
446
- borderTopRightRadius: 16,
447
- borderBottomRightRadius: 16,
448
- }
449
- : {
450
- right: 0,
451
- borderTopLeftRadius: 0,
452
- borderBottomLeftRadius: 0,
453
- };
519
+ ? { left: 0, borderTopLeftRadius: 0, borderBottomLeftRadius: 0, borderTopRightRadius: 16, borderBottomRightRadius: 16 }
520
+ : { right: 0, borderTopLeftRadius: 0, borderBottomLeftRadius: 0 };
454
521
 
455
- /* ── Don't render until mounted (SSR safe) ──────────────────────────── */
456
522
  if (!mounted) return null;
457
523
 
458
524
  return (
459
- <>
525
+ <ErrorBoundary primaryColor={primaryColor}>
460
526
  {/* ── Global styles ── */}
461
527
  <style>{`
462
528
  @import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700;800&display=swap');
463
-
464
529
  .cw-root * { box-sizing: border-box; font-family: 'DM Sans', 'Segoe UI', sans-serif; }
465
-
466
530
  @keyframes cw-slideInRight { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
467
531
  @keyframes cw-slideOutRight { from { transform: translateX(0); opacity: 1; } to { transform: translateX(100%); opacity: 0; } }
468
532
  @keyframes cw-slideInLeft { from { transform: translateX(-100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
@@ -471,25 +535,30 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme, viewe
471
535
  @keyframes cw-slideIn { from { opacity: 0; transform: translateX(18px); } to { opacity: 1; transform: translateX(0); } }
472
536
  @keyframes cw-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
473
537
  @keyframes cw-btnPop { from { transform: scale(0.8); opacity: 0; } to { transform: scale(1); opacity: 1; } }
474
-
538
+ @keyframes spin { to { transform: rotate(360deg); } }
475
539
  .cw-scroll::-webkit-scrollbar { width: 4px; }
476
540
  .cw-scroll::-webkit-scrollbar-track { background: transparent; }
477
541
  .cw-scroll::-webkit-scrollbar-thumb { background: #e0e0e0; border-radius: 4px; }
478
-
479
542
  .cw-drawer-enter { animation: ${theme.buttonPosition === 'bottom-left' ? 'cw-slideInLeft' : 'cw-slideInRight'} 0.32s cubic-bezier(0.22,1,0.36,1) both; }
480
543
  .cw-drawer-exit { animation: ${theme.buttonPosition === 'bottom-left' ? 'cw-slideOutLeft' : 'cw-slideOutRight'} 0.28s cubic-bezier(0.55,0,1,0.45) both; }
481
-
482
- .cw-drawer-panel {
483
- width: 30%;
484
- max-width: 100vw;
485
- min-width: 0;
486
- }
487
- @media (max-width: 1024px) {
488
- .cw-drawer-panel { width: 100%; }
489
- }
544
+ .cw-drawer-panel { width: 30%; max-width: 100vw; min-width: 0; }
545
+ @media (max-width: 1024px) { .cw-drawer-panel { width: 100%; } }
490
546
  `}</style>
491
547
 
492
- {/* ── Minimized call bar (drawer closed, call still active) ── */}
548
+ {/* ── Socket status dot (subtle, only visible when not connected) ── */}
549
+ {socketStatus !== 'connected' && socketStatus !== 'disconnected' && (
550
+ <div style={{
551
+ position: 'fixed', bottom: 80, ...posStyle, zIndex: 9996,
552
+ background: socketStatus === 'connecting' ? '#f59e0b' : '#ef4444',
553
+ color: '#fff', fontSize: 11, fontWeight: 700,
554
+ padding: '3px 10px', borderRadius: 20,
555
+ animation: 'cw-fadeUp 0.3s ease',
556
+ }}>
557
+ {socketStatus === 'connecting' ? '● Connecting…' : '● Offline'}
558
+ </div>
559
+ )}
560
+
561
+ {/* ── Minimized call bar ── */}
493
562
  {!isOpen && callMinimized && callInProgress && callSession.peer && (
494
563
  <MiniCallBar
495
564
  session={callSession}
@@ -500,7 +569,7 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme, viewe
500
569
  />
501
570
  )}
502
571
 
503
- {/* ── Floating Button (unread badge + tooltip when closed) ── */}
572
+ {/* ── Floating Button ── */}
504
573
  {!isOpen && (
505
574
  <button
506
575
  className="cw-root"
@@ -509,12 +578,10 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme, viewe
509
578
  aria-label={totalUnread > 0 ? `${theme.buttonLabel}, ${totalUnread} unread` : theme.buttonLabel}
510
579
  title={totalUnread > 0 ? `${totalUnread} unread message${totalUnread === 1 ? '' : 's'}` : theme.buttonLabel}
511
580
  style={{
512
- position: 'fixed', bottom: 24, zIndex: 9999,
513
- ...posStyle,
581
+ position: 'fixed', bottom: 24, zIndex: 9999, ...posStyle,
514
582
  display: 'flex', alignItems: 'center', gap: 10,
515
583
  padding: '13px 22px',
516
- backgroundColor: theme.buttonColor,
517
- color: theme.buttonTextColor,
584
+ backgroundColor: theme.buttonColor, color: theme.buttonTextColor,
518
585
  border: 'none', borderRadius: 50,
519
586
  cursor: 'pointer', fontSize: 15, fontWeight: 700,
520
587
  boxShadow: `0 8px 28px ${theme.buttonColor}55`,
@@ -536,25 +603,12 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme, viewe
536
603
  stroke={theme.buttonTextColor} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
537
604
  </svg>
538
605
  {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
- >
606
+ <span style={{
607
+ position: 'absolute', top: -8, right: -10,
608
+ minWidth: 20, height: 20, padding: '0 5px', borderRadius: 999,
609
+ background: '#ef4444', color: '#fff', fontSize: 11, fontWeight: 800,
610
+ lineHeight: '20px', textAlign: 'center', border: '2px solid #fff', boxSizing: 'border-box',
611
+ }}>
558
612
  {totalUnread > 99 ? '99+' : totalUnread}
559
613
  </span>
560
614
  )}
@@ -563,7 +617,7 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme, viewe
563
617
  </button>
564
618
  )}
565
619
 
566
- {/* ── Backdrop (visual only — does not close widget on click) ── */}
620
+ {/* ── Backdrop ── */}
567
621
  {isOpen && (
568
622
  <div
569
623
  aria-hidden
@@ -576,59 +630,47 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme, viewe
576
630
  />
577
631
  )}
578
632
 
579
- {/* ── Drawer / Slider ── */}
633
+ {/* ── Drawer ── */}
580
634
  {isOpen && (
581
635
  <div
582
636
  className={`cw-root cw-drawer-panel ${closing ? 'cw-drawer-exit' : 'cw-drawer-enter'}`}
583
637
  style={{
584
- position: 'fixed',
585
- top: 0,
586
- bottom: 0,
587
- ...drawerPosStyle,
588
- zIndex: 9998,
638
+ position: 'fixed', top: 0, bottom: 0, ...drawerPosStyle, zIndex: 9998,
589
639
  backgroundColor: '#fff',
590
640
  boxShadow: theme.buttonPosition === 'bottom-left'
591
641
  ? '4px 0 40px rgba(0,0,0,0.18)'
592
642
  : '-4px 0 40px rgba(0,0,0,0.18)',
593
- display: 'flex',
594
- flexDirection: 'column',
595
- overflow: 'hidden',
643
+ display: 'flex', flexDirection: 'column', overflow: 'hidden',
596
644
  }}
597
645
  >
598
- {/* ── Loading state ── */}
646
+ {/* Loading */}
599
647
  {cfgLoading && (
600
- <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 16 }}>
601
- <div style={{
602
- width: 40, height: 40, borderRadius: '50%',
603
- border: `3px solid ${primaryColor}30`,
604
- borderTopColor: primaryColor,
605
- animation: 'spin 0.8s linear infinite',
606
- }} />
607
- <style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
608
- <p style={{ fontSize: 14, color: '#7b8fa1' }}>Loading chat…</p>
648
+ <div style={{ display:'flex',flexDirection:'column',alignItems:'center',justifyContent:'center',height:'100%',gap:16 }}>
649
+ <div style={{ width:40,height:40,borderRadius:'50%',border:`3px solid ${primaryColor}30`,borderTopColor:primaryColor,animation:'spin 0.8s linear infinite' }} />
650
+ <p style={{ fontSize:14,color:'#7b8fa1' }}>Loading chat…</p>
609
651
  </div>
610
652
  )}
611
653
 
612
- {/* ── Error state ── */}
654
+ {/* Error */}
613
655
  {cfgError && !cfgLoading && (
614
- <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 12, padding: 32, textAlign: 'center' }}>
615
- <div style={{ fontSize: 40 }}>⚠️</div>
616
- <p style={{ fontWeight: 700, color: '#1a2332' }}>Could not load chat configuration</p>
617
- <p style={{ fontSize: 13, color: '#7b8fa1', lineHeight: 1.6 }}>{cfgError}</p>
618
- <button onClick={closeDrawer} style={{ padding: '9px 20px', borderRadius: 10, border: 'none', background: primaryColor, color: '#fff', cursor: 'pointer', fontWeight: 700 }}>Close</button>
656
+ <div style={{ display:'flex',flexDirection:'column',alignItems:'center',justifyContent:'center',height:'100%',gap:12,padding:32,textAlign:'center' }}>
657
+ <div style={{ fontSize:40 }}>⚠️</div>
658
+ <p style={{ fontWeight:700,color:'#1a2332' }}>Could not load chat configuration</p>
659
+ <p style={{ fontSize:13,color:'#7b8fa1',lineHeight:1.6 }}>{cfgError}</p>
660
+ <button onClick={closeDrawer} style={{ padding:'9px 20px',borderRadius:10,border:'none',background:primaryColor,color:'#fff',cursor:'pointer',fontWeight:700 }}>Close</button>
619
661
  </div>
620
662
  )}
621
663
 
622
- {/* ── Main content ── */}
664
+ {/* Main content */}
623
665
  {!cfgLoading && !cfgError && widgetConfig && (
624
- <>
625
- {/* Resize + Close controls — hidden on blocked screen (Close is in-panel) */}
666
+ <ErrorBoundary primaryColor={primaryColor}>
667
+ {/* Close button */}
626
668
  {screen !== 'chat' && screen !== 'call' && !effectiveViewerBlocked && (
627
669
  <div style={{
628
- position: 'absolute', top: 12,
670
+ position:'absolute',top:12,
629
671
  right: theme.buttonPosition === 'bottom-left' ? 'auto' : 12,
630
- left: theme.buttonPosition === 'bottom-left' ? 12 : 'auto',
631
- zIndex: 20, display: 'flex', gap: 6,
672
+ left: theme.buttonPosition === 'bottom-left' ? 12 : 'auto',
673
+ zIndex:20,display:'flex',gap:6,
632
674
  }}>
633
675
  <CornerBtn onClick={closeDrawer} title="Close">
634
676
  <svg width="12" height="12" viewBox="0 0 24 24" fill="none">
@@ -638,12 +680,8 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme, viewe
638
680
  </div>
639
681
  )}
640
682
 
641
- {/* ── MAINTENANCE ── */}
642
- {widgetConfig.status === 'MAINTENANCE' && (
643
- <MaintenanceView primaryColor={primaryColor} />
644
- )}
683
+ {widgetConfig.status === 'MAINTENANCE' && <MaintenanceView primaryColor={primaryColor} />}
645
684
 
646
- {/* ── DISABLED ── */}
647
685
  {widgetConfig.status === 'DISABLE' && (
648
686
  <div style={{ display:'flex',flexDirection:'column',alignItems:'center',justifyContent:'center',height:'100%',padding:32,textAlign:'center',gap:12 }}>
649
687
  <div style={{ fontSize:40 }}>🔒</div>
@@ -652,12 +690,10 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme, viewe
652
690
  </div>
653
691
  )}
654
692
 
655
- {/* ── ACTIVE: viewer spam-blocked (no chat/tickets UI) ── */}
656
693
  {widgetConfig.status === 'ACTIVE' && effectiveViewerBlocked && (
657
694
  <ViewerBlockedScreen config={widgetConfig} apiKey={apiKey} onClose={closeDrawer} />
658
695
  )}
659
696
 
660
- {/* ── ACTIVE: microphone, location, screen share required ── */}
661
697
  {widgetConfig.status === 'ACTIVE' && !effectiveViewerBlocked && !permissionsOk && (
662
698
  <PermissionsGateScreen
663
699
  primaryColor={primaryColor}
@@ -666,14 +702,12 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme, viewe
666
702
  />
667
703
  )}
668
704
 
669
- {/* ── ACTIVE ── */}
670
705
  {widgetConfig.status === 'ACTIVE' && !effectiveViewerBlocked && permissionsOk && (
671
- <div className="cw-scroll" style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
706
+ <div className="cw-scroll" style={{ flex:1,display:'flex',flexDirection:'column',overflow:'hidden' }}>
672
707
 
673
708
  {screen === 'home' && (
674
709
  <HomeScreen
675
- config={widgetConfig}
676
- apiKey={apiKey}
710
+ config={widgetConfig} apiKey={apiKey}
677
711
  onNavigate={handleCardClick}
678
712
  onOpenTicket={handleOpenTicket}
679
713
  tickets={tickets}
@@ -706,7 +740,7 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme, viewe
706
740
  onBack={handleBackFromChat}
707
741
  onClose={closeDrawer}
708
742
  onTogglePause={handleTogglePause}
709
- onReport={reportChat}
743
+ onReport={_reportChat}
710
744
  onBlock={handleBlock}
711
745
  onStartCall={handleStartCall}
712
746
  onNavAction={handleNavFromMenu}
@@ -761,18 +795,16 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme, viewe
761
795
  />
762
796
  )}
763
797
 
764
- {screen === 'ticket-detail' && viewingTicketId && (
765
- (() => {
766
- const t = tickets.find(x => x.id === viewingTicketId);
767
- return t ? (
768
- <TicketDetailScreen
769
- ticket={t}
770
- config={widgetConfig}
771
- onBack={() => { setViewingTicketId(null); setScreen('tickets'); }}
772
- />
773
- ) : null;
774
- })()
775
- )}
798
+ {screen === 'ticket-detail' && viewingTicketId && (() => {
799
+ const t = tickets.find(x => x.id === viewingTicketId);
800
+ return t ? (
801
+ <TicketDetailScreen
802
+ ticket={t}
803
+ config={widgetConfig}
804
+ onBack={() => { setViewingTicketId(null); setScreen('tickets'); }}
805
+ />
806
+ ) : null;
807
+ })()}
776
808
 
777
809
  {screen === 'block-list' && (
778
810
  <BlockListScreen
@@ -785,38 +817,28 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme, viewe
785
817
  </div>
786
818
  )}
787
819
 
788
- {/* ── Bottom Tabs (hidden during chat/call/user-list/block-list) ── */}
820
+ {/* Bottom Tabs */}
789
821
  {widgetConfig.status === 'ACTIVE' &&
790
- !effectiveViewerBlocked &&
791
- permissionsOk &&
792
- screen !== 'chat' &&
793
- screen !== 'call' &&
794
- screen !== 'user-list' &&
795
- screen !== 'block-list' &&
796
- screen !== 'ticket-detail' &&
797
- screen !== 'ticket-new' && (
798
- <BottomTabs
799
- active={activeTab}
800
- onChange={handleTabChange}
801
- primaryColor={primaryColor}
802
- />
822
+ !effectiveViewerBlocked && permissionsOk &&
823
+ screen !== 'chat' && screen !== 'call' &&
824
+ screen !== 'user-list' && screen !== 'block-list' &&
825
+ screen !== 'ticket-detail' && screen !== 'ticket-new' && (
826
+ <BottomTabs active={activeTab} onChange={handleTabChange} primaryColor={primaryColor} />
803
827
  )}
804
- </>
828
+ </ErrorBoundary>
805
829
  )}
806
830
  </div>
807
831
  )}
808
- </>
832
+ </ErrorBoundary>
809
833
  );
810
834
  };
811
835
 
812
836
  export default ChatWidget;
813
837
 
814
- /* ── Tiny corner button ────────────────────────────────────────────────────── */
815
838
  const CornerBtn: React.FC<{ onClick: () => void; title: string; children: React.ReactNode }> = ({ onClick, title, children }) => (
816
839
  <button onClick={onClick} title={title} style={{
817
- width: 26, height: 26, borderRadius: '50%',
818
- background: 'rgba(0,0,0,0.25)', border: 'none',
819
- display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer',
840
+ width:26,height:26,borderRadius:'50%',background:'rgba(0,0,0,0.25)',border:'none',
841
+ display:'flex',alignItems:'center',justifyContent:'center',cursor:'pointer',
820
842
  }}>
821
843
  {children}
822
844
  </button>