ajaxter-chat 3.0.17 → 3.2.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 (146) 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 +499 -367
  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 +7 -3
  74. package/dist/config/index.d.ts.map +1 -0
  75. package/dist/config/index.js +73 -22
  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 +22 -15
  84. package/dist/hooks/useRemoteConfig.js.map +1 -0
  85. package/dist/hooks/useSocket.d.ts +59 -0
  86. package/dist/hooks/useSocket.d.ts.map +1 -0
  87. package/dist/hooks/useSocket.js +203 -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 +7 -1
  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 +129 -48
  98. package/dist/types/index.d.ts.map +1 -0
  99. package/dist/types/index.js +4 -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 +8 -4
  138. package/src/components/ChatWidget.tsx +643 -622
  139. package/src/components/ErrorBoundary/index.tsx +62 -0
  140. package/src/config/index.ts +87 -26
  141. package/src/hooks/useChat.ts +59 -12
  142. package/src/hooks/useRemoteConfig.ts +8 -3
  143. package/src/hooks/useSocket.ts +249 -0
  144. package/src/hooks/useWebRTC.ts +99 -64
  145. package/src/index.ts +14 -3
  146. package/src/types/index.ts +177 -143
@@ -1,142 +1,294 @@
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, DisplayMode,
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
+ // ─── Display-mode geometry ────────────────────────────────────────────────────
35
+
36
+ interface PanelGeometry {
37
+ /** Fixed-position style for the panel */
38
+ panel: React.CSSProperties;
39
+ enterClass: string;
40
+ exitClass: string;
41
+ /** CSS for the backdrop (undefined = no backdrop) */
42
+ backdrop?: React.CSSProperties;
43
+ }
44
+
45
+ function getPanelGeometry(
46
+ displayMode: DisplayMode,
47
+ buttonPosition: 'bottom-right' | 'bottom-left',
48
+ ajaxterMaximized?: { desktop?: { height?: number; width?: number } }
49
+ ): PanelGeometry {
50
+ const w = ajaxterMaximized?.desktop?.width ?? 550;
51
+ const h = ajaxterMaximized?.desktop?.height ?? 600;
52
+
53
+ if (displayMode === 'popup') {
54
+ // Centered modal / popup
55
+ return {
56
+ panel: {
57
+ position: 'fixed',
58
+ top: '50%',
59
+ left: '50%',
60
+ transform: 'translate(-50%, -50%)',
61
+ width: `min(${w}px, 96vw)`,
62
+ height: `min(${h}px, 94vh)`,
63
+ maxWidth: '100vw',
64
+ maxHeight: '100vh',
65
+ borderRadius: 16,
66
+ boxShadow: '0 24px 64px rgba(0,0,0,0.28)',
67
+ zIndex: 9998,
68
+ backgroundColor: '#fff',
69
+ display: 'flex',
70
+ flexDirection: 'column',
71
+ overflow: 'hidden',
72
+ },
73
+ enterClass: 'cw-popup-enter',
74
+ exitClass: 'cw-popup-exit',
75
+ backdrop: {
76
+ position: 'fixed', inset: 0, zIndex: 9997,
77
+ backgroundColor: 'rgba(0,0,0,0.45)',
78
+ backdropFilter: 'blur(2px)',
79
+ },
80
+ };
81
+ }
82
+
83
+ if (displayMode === 'inline') {
84
+ // Embedded — no fixed positioning, caller wraps it
85
+ return {
86
+ panel: {
87
+ position: 'relative',
88
+ width: '100%',
89
+ height: '100%',
90
+ display: 'flex',
91
+ flexDirection: 'column',
92
+ overflow: 'hidden',
93
+ backgroundColor: '#fff',
94
+ },
95
+ enterClass: '',
96
+ exitClass: '',
97
+ };
98
+ }
99
+
100
+ // Default: slider (side drawer)
101
+ const isLeft = buttonPosition === 'bottom-left';
102
+ return {
103
+ panel: {
104
+ position: 'fixed',
105
+ top: 0, bottom: 0,
106
+ [isLeft ? 'left' : 'right']: 0,
107
+ width: 'min(380px, 100vw)',
108
+ maxWidth: '100vw',
109
+ borderTopLeftRadius: isLeft ? 0 : 16,
110
+ borderBottomLeftRadius: isLeft ? 0 : 16,
111
+ borderTopRightRadius: isLeft ? 16 : 0,
112
+ borderBottomRightRadius: isLeft ? 16 : 0,
113
+ boxShadow: isLeft ? '4px 0 40px rgba(0,0,0,0.18)' : '-4px 0 40px rgba(0,0,0,0.18)',
114
+ zIndex: 9998,
115
+ backgroundColor: '#fff',
116
+ display: 'flex',
117
+ flexDirection: 'column',
118
+ overflow: 'hidden',
119
+ },
120
+ enterClass: isLeft ? 'cw-slideInLeft' : 'cw-slideInRight',
121
+ exitClass: isLeft ? 'cw-slideOutLeft' : 'cw-slideOutRight',
122
+ backdrop: {
123
+ position: 'fixed', inset: 0, zIndex: 9997,
124
+ backgroundColor: 'rgba(0,0,0,0.35)',
125
+ },
126
+ };
127
+ }
128
+
129
+ // ─── ChatWidget ───────────────────────────────────────────────────────────────
130
+
29
131
  export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme, viewer }) => {
30
- /* SSR guard */
31
132
  const [mounted, setMounted] = useState(false);
32
133
  useEffect(() => { setMounted(true); }, []);
33
134
 
34
- /* Env config */
35
- const { apiKey, widgetId } = loadLocalConfig();
36
-
37
- /* Remote config */
135
+ const { apiKey, widgetId, socketUrl } = loadLocalConfig();
38
136
  const { data, loading: cfgLoading, error: cfgError } = useRemoteConfig(apiKey, widgetId);
39
137
 
40
- /* Merged theme — remote config overrides defaults, local prop overrides both */
41
138
  const theme = mergeTheme(
42
- data?.widget ? { primaryColor: data.widget.primaryColor, buttonLabel: data.widget.buttonLabel, buttonPosition: data.widget.buttonPosition } : undefined,
43
- localTheme
139
+ data?.widget ? {
140
+ primaryColor: data.widget.primaryColor,
141
+ buttonLabel: data.widget.buttonLabel,
142
+ buttonPosition: data.widget.buttonPosition,
143
+ } : undefined,
144
+ localTheme,
44
145
  );
45
146
 
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. */
147
+ // Pull real colours from Ajaxter settings if available
148
+ const headerBg = data?.widget.ajaxterSettings?.theme?.header?.background ?? theme.primaryColor;
149
+ const agentBg = data?.widget.ajaxterSettings?.theme?.agent?.messageBackground ?? '#54aadd';
150
+ const visitorBg = data?.widget.ajaxterSettings?.theme?.visitor?.messageBackground ?? '#e5e5e5';
151
+ const displayMode: DisplayMode = data?.widget.displayMode ?? 'slider';
152
+ const ajaxterSettings = data?.widget.ajaxterSettings;
153
+
154
+ // Minimized button style from Ajaxter settings
155
+ const minimizedDesktop = ajaxterSettings?.minimized?.desktop;
156
+ const btnType = minimizedDesktop?.type ?? 'slide';
157
+
158
+ const [isOpen, setIsOpen] = useState(false);
159
+ const [closing, setClosing] = useState(false);
50
160
  const [callMinimized, setCallMinimized] = useState(false);
51
161
 
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');
162
+ const [activeTab, setActiveTab] = useState<BottomTab>('home');
163
+ const [screen, setScreen] = useState<Screen>('home');
164
+ const [userListCtx, setUserListCtx] = useState<UserListContext>('support');
165
+ const [chatReturnCtx, setChatReturnCtx] = useState<UserListContext>('conversation');
57
166
  const [viewingTicketId, setViewingTicketId] = useState<string | null>(null);
58
167
  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);
168
+ const [listEntranceAnimation, setListEntranceAnimation] = useState(false);
169
+ const [permissionsOk, setPermissionsOk] = useState(false);
170
+ const [socketStatus, setSocketStatus] = useState<'connecting'|'connected'|'disconnected'|'error'>('disconnected');
171
+ const [typingUsers, setTypingUsers] = useState<Set<string>>(new Set());
63
172
 
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 ?? []);
173
+ const [tickets, setTickets] = useState<Ticket[]>([]);
174
+ const [recentChats, setRecentChats] = useState<RecentChat[]>([]);
175
+ const [blockedUids, setBlockedUids] = useState<string[]>([]);
68
176
 
69
- /* Sync remote data into local state once loaded */
177
+ // Sync remote data once loaded
70
178
  useEffect(() => {
71
- if (data) {
72
- setTickets(data.sampleTickets);
73
- setBlockedUids(data.blockedUsers);
74
- const pid = viewer?.projectId?.trim();
75
- const devs = data.developers ?? [];
76
- const usr = pid ? (data.users ?? []).filter(u => u.project === pid) : (data.users ?? []);
77
- const all = [...devs, ...usr];
78
- const recents: RecentChat[] = Object.entries(data.sampleChats).map(([uid, msgs]) => {
79
- const user = all.find(u => u.uid === uid);
80
- if (!user || msgs.length === 0) return null;
81
- const last = msgs[msgs.length - 1];
82
- return {
83
- id: `rc_${uid}`,
84
- user,
85
- lastMessage: last.text,
86
- lastTime: last.timestamp,
87
- unread: Math.floor(Math.random() * 3),
88
- isPaused: false,
89
- };
90
- }).filter(Boolean) as RecentChat[];
91
- setRecentChats(recents);
92
- }
179
+ if (!data) return;
180
+ setTickets(data.sampleTickets);
181
+ setBlockedUids(data.blockedUsers);
182
+ const pid = viewer?.projectId?.trim();
183
+ const devs = data.developers ?? [];
184
+ const usr = pid ? (data.users ?? []).filter(u => u.project === pid) : (data.users ?? []);
185
+ const all = [...devs, ...usr];
186
+ const recents: RecentChat[] = Object.entries(data.sampleChats).map(([uid, msgs]) => {
187
+ const user = all.find(u => u.uid === uid);
188
+ if (!user || msgs.length === 0) return null;
189
+ const last = msgs[msgs.length - 1];
190
+ return { id: `rc_${uid}`, user, lastMessage: last.text, lastTime: last.timestamp, unread: 0, isPaused: false };
191
+ }).filter(Boolean) as RecentChat[];
192
+ setRecentChats(recents);
93
193
  }, [data, viewer?.projectId]);
94
194
 
95
- /* Chat hook */
195
+ // ── Socket ────────────────────────────────────────────────────────────────
196
+ const viewerUidForSocket = (viewer?.uid ?? data?.widget?.viewerUid ?? '').trim();
197
+
198
+ const {
199
+ joinRoom, leaveRoom,
200
+ emitMessage, emitPauseToggle, emitReport,
201
+ emitBlock, emitUnblock, emitTransfer,
202
+ emitCallOffer, emitCallAnswer, emitIceCandidate, emitCallEnd,
203
+ emitAddParticipant, emitPresence,
204
+ emitTicketCreate, emitTicketUpdate,
205
+ status: _socketStatus,
206
+ } = useSocket({
207
+ widgetId,
208
+ viewerUid: viewerUidForSocket,
209
+ serverUrl: socketUrl,
210
+ onMessage: (msg) => {
211
+ receiveMessage(msg);
212
+ if (messageSoundEnabled && msg.senderId !== 'me') playMessageSound();
213
+ setRecentChats(prev => prev.map(r =>
214
+ r.user.uid === msg.senderId || r.user.uid === msg.receiverId
215
+ ? { ...r, lastMessage: msg.text, lastTime: msg.timestamp, unread: r.unread + 1 }
216
+ : r
217
+ ));
218
+ },
219
+ onMessageAck: ({ messageId, status }) => { updateMessageStatus(messageId, status); },
220
+ onChatPaused: (_roomId, paused) => {
221
+ if (activeUser) setRecentChats(prev => prev.map(r =>
222
+ r.user.uid === activeUser.uid ? { ...r, isPaused: paused } : r
223
+ ));
224
+ },
225
+ onTypingStart: (roomId, userId) => {
226
+ setTypingUsers(prev => { const s = new Set(prev); s.add(userId); return s; });
227
+ },
228
+ onTypingStop: (_roomId, userId) => {
229
+ setTypingUsers(prev => { const s = new Set(prev); s.delete(userId); return s; });
230
+ },
231
+ onCallOffer: async (offer, fromUid, callId) => {
232
+ const allUsers = [...(data?.developers ?? []), ...(data?.users ?? [])];
233
+ const peer = allUsers.find(u => u.uid === fromUid);
234
+ if (peer) { await acceptCall(offer, peer, callId); setScreen('call'); setIsOpen(true); }
235
+ },
236
+ onCallEnd: () => { _endCall(); },
237
+ onUserStatus: (uid, status) => {
238
+ setRecentChats(prev => prev.map(r =>
239
+ r.user.uid === uid ? { ...r, user: { ...r.user, status } } : r
240
+ ));
241
+ },
242
+ onTicketCreated: (t) => { if (t) setTickets(prev => [t as Ticket, ...prev]); },
243
+ onTicketUpdated: (t) => {
244
+ if (t) setTickets(prev => prev.map(x => x.id === (t as Ticket).id ? t as Ticket : x));
245
+ },
246
+ onError: (code, msg) => console.error('[ChatWidget] socket error:', code, msg),
247
+ });
248
+
249
+ useEffect(() => { setSocketStatus(_socketStatus); }, [_socketStatus]);
250
+
251
+ // ── Chat hook ─────────────────────────────────────────────────────────────
96
252
  const {
97
253
  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();
103
-
104
- const callInProgress =
105
- callSession.state === 'calling' || callSession.state === 'connected';
106
-
107
- useEffect(() => {
108
- if (!callInProgress) setCallMinimized(false);
109
- }, [callInProgress]);
110
-
111
- /* ── Drawer open/close with slide animation ───────────────────────────── */
112
- const openDrawer = () => {
113
- setClosing(false);
114
- setIsOpen(true);
115
- setCallMinimized(false);
116
- };
254
+ selectUser, sendMessage, receiveMessage, updateMessageStatus,
255
+ togglePause: _togglePause, reportChat: _reportChat, clearChat, setMessages,
256
+ } = useChat([], {
257
+ onEmitMessage: (msg) => emitMessage(msg),
258
+ onEmitPause: (roomId, targetUid, paused) => emitPauseToggle(roomId, targetUid, paused),
259
+ onEmitReport: (roomId) => emitReport(roomId),
260
+ });
261
+
262
+ // ── WebRTC hook ───────────────────────────────────────────────────────────
263
+ const {
264
+ session: callSession, localVideoRef, remoteVideoRef,
265
+ startCall: _startCall, acceptCall, addIceCandidate, endCall: _endCall,
266
+ toggleMute, toggleCamera,
267
+ } = useWebRTC({
268
+ onOfferReady: (offer, toUid, callId) => emitCallOffer(offer, toUid, callId),
269
+ onAnswerReady: (answer, toUid, callId) => emitCallAnswer(answer, toUid, callId),
270
+ onIceCandidateReady:(candidate, toUid) => emitIceCandidate(candidate, toUid),
271
+ onCallEnded: (callId, toUid) => emitCallEnd(callId, toUid),
272
+ });
273
+
274
+ const callInProgress = callSession.state === 'calling' || callSession.state === 'connected';
275
+ useEffect(() => { if (!callInProgress) setCallMinimized(false); }, [callInProgress]);
276
+
277
+ // ── Drawer open / close ───────────────────────────────────────────────────
278
+ const openDrawer = () => { setClosing(false); setIsOpen(true); setCallMinimized(false); };
117
279
 
118
280
  const persistWidgetState = useCallback(() => {
119
281
  const w = data?.widget;
120
282
  if (!w) return;
121
- saveSession(w.id, {
122
- screen,
123
- activeTab,
124
- userListCtx,
125
- activeUserUid: activeUser?.uid ?? null,
126
- messages,
127
- viewingTicketId,
128
- chatReturnCtx,
129
- });
283
+ saveSession(w.id, { screen, activeTab, userListCtx, activeUserUid: activeUser?.uid ?? null, messages, viewingTicketId, chatReturnCtx });
130
284
  }, [data?.widget, screen, activeTab, userListCtx, activeUser?.uid, messages, viewingTicketId, chatReturnCtx]);
131
285
 
132
286
  const closeDrawer = useCallback(() => {
133
287
  persistWidgetState();
288
+ if (displayMode === 'inline') return; // inline is always open
134
289
  setClosing(true);
135
- setTimeout(() => {
136
- setIsOpen(false);
137
- setClosing(false);
138
- }, 300);
139
- }, [persistWidgetState]);
290
+ setTimeout(() => { setIsOpen(false); setClosing(false); }, 300);
291
+ }, [persistWidgetState, displayMode]);
140
292
 
141
293
  useEffect(() => {
142
294
  const id = data?.widget?.id;
@@ -144,115 +296,53 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme, viewe
144
296
  setPermissionsOk(hasStoredPermissionsGrant(id));
145
297
  }, [data?.widget?.id]);
146
298
 
299
+ // Session restore
147
300
  const restoredRef = useRef(false);
148
301
  useEffect(() => {
149
302
  if (!data?.widget || restoredRef.current) return;
150
303
  const w = data.widget;
151
304
  setMessageSoundEnabledState(getMessageSoundEnabled(w.id));
152
- const uidForBlock = (viewer?.uid ?? w.viewerUid)?.trim();
153
- let viewerIsBlocked = w.viewerBlocked === true;
154
- if (!viewerIsBlocked && uidForBlock) {
155
- const rec = [...data.developers, ...data.users].find(x => x.uid === uidForBlock);
156
- viewerIsBlocked = rec?.viewerBlocked === true;
157
- }
158
- if (viewerIsBlocked) {
159
- clearChat();
160
- setScreen('home');
161
- setActiveTab('home');
162
- setViewingTicketId(null);
163
- restoredRef.current = true;
164
- return;
165
- }
166
305
  const p = loadSession(w.id);
167
306
  if (p) {
168
- setScreen(p.screen);
169
- setActiveTab(p.activeTab);
170
- setUserListCtx(p.userListCtx);
171
- setViewingTicketId(p.viewingTicketId ?? null);
307
+ setScreen(p.screen); setActiveTab(p.activeTab);
308
+ setUserListCtx(p.userListCtx); setViewingTicketId(p.viewingTicketId ?? null);
172
309
  setChatReturnCtx(p.chatReturnCtx ?? 'conversation');
173
310
  if (p.activeUserUid) {
174
- const pid = viewer?.projectId?.trim();
175
- const pool = pid
176
- ? [...data.developers, ...data.users].filter(u => u.project === pid)
177
- : [...data.developers, ...data.users];
311
+ const pool = [...data.developers, ...data.users];
178
312
  const u = pool.find(x => x.uid === p.activeUserUid);
179
313
  if (u) {
180
- const hist = Array.isArray(p.messages) && p.messages.length
181
- ? p.messages
182
- : (data.sampleChats[u.uid] ?? []);
314
+ const hist = Array.isArray(p.messages) && p.messages.length ? p.messages : (data.sampleChats[u.uid] ?? []);
183
315
  selectUser(u, hist);
316
+ const roomId = [u.uid, viewerUidForSocket].sort().join('_');
317
+ joinRoom(roomId);
184
318
  }
185
319
  }
186
320
  }
187
321
  restoredRef.current = true;
188
- }, [data, selectUser, clearChat, viewer?.projectId, viewer?.uid]);
189
-
190
- useEffect(() => {
191
- if (!data?.widget) return;
192
- const w = data.widget;
193
- const uid = (viewer?.uid ?? w.viewerUid)?.trim();
194
- let blocked = w.viewerBlocked === true;
195
- if (!blocked && uid) {
196
- const rec = [...data.developers, ...data.users].find(x => x.uid === uid);
197
- blocked = rec?.viewerBlocked === true;
198
- }
199
- if (!blocked) return;
200
- clearChat();
201
- setScreen('home');
202
- setActiveTab('home');
203
- setViewingTicketId(null);
204
- }, [data?.widget, data?.developers, data?.users, viewer?.uid, clearChat]);
322
+ }, [data, selectUser, viewer?.projectId, viewer?.uid, viewerUidForSocket, joinRoom]);
205
323
 
206
324
  useEffect(() => {
207
325
  if (!data?.widget) return;
208
326
  persistWidgetState();
209
327
  }, [data?.widget?.id, screen, activeTab, userListCtx, activeUser?.uid, messages, viewingTicketId, chatReturnCtx, persistWidgetState]);
210
328
 
211
- 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]);
226
-
227
329
  const toggleMessageSound = useCallback((enabled: boolean) => {
228
- const w = data?.widget;
229
- if (!w) return;
330
+ const w = data?.widget; if (!w) return;
230
331
  setMessageSoundEnabled(w.id, enabled);
231
332
  setMessageSoundEnabledState(enabled);
232
333
  }, [data?.widget]);
233
334
 
234
- /* ── Navigation ──────────────────────────────────────────────────────── */
335
+ // ── Navigation ────────────────────────────────────────────────────────────
235
336
  const handleCardClick = useCallback((ctx: UserListContext | 'ticket', options?: { fromMenu?: boolean }) => {
236
337
  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
- }
338
+ if (ctx === 'ticket') { setActiveTab('tickets'); setScreen('tickets'); }
339
+ else { setUserListCtx(ctx as UserListContext); setScreen('user-list'); }
244
340
  }, []);
245
341
 
246
342
  const handleNavFromMenu = useCallback((ctx: UserListContext | 'ticket') => {
247
- setListEntranceAnimation(false);
248
- clearChat();
249
- if (ctx === 'ticket') {
250
- setActiveTab('tickets');
251
- setScreen('tickets');
252
- } else {
253
- setUserListCtx(ctx);
254
- setScreen('user-list');
255
- }
343
+ setListEntranceAnimation(false); clearChat();
344
+ if (ctx === 'ticket') { setActiveTab('tickets'); setScreen('tickets'); }
345
+ else { setUserListCtx(ctx); setScreen('user-list'); }
256
346
  }, [clearChat]);
257
347
 
258
348
  const listCtxForUser = useCallback((user: ChatUser, viewerIsDev: boolean): UserListContext => {
@@ -266,30 +356,27 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme, viewe
266
356
  const history = data?.sampleChats[user.uid] ?? [];
267
357
  selectUser(user, history);
268
358
  setScreen('chat');
359
+ const roomId = [user.uid, viewerUidForSocket].sort().join('_');
360
+ joinRoom(roomId);
269
361
  setRecentChats(prev => {
270
362
  const exists = prev.find(r => r.user.uid === user.uid);
271
- if (exists) return prev;
363
+ if (exists) return prev.map(r => r.user.uid === user.uid ? { ...r, unread: 0 } : r);
272
364
  return [{ id: `rc_${user.uid}`, user, lastMessage: '', lastTime: new Date().toISOString(), unread: 0, isPaused: false }, ...prev];
273
365
  });
274
- }, [data, selectUser, userListCtx]);
366
+ }, [data, selectUser, userListCtx, viewerUidForSocket, joinRoom]);
275
367
 
276
368
  const handleBackFromChat = useCallback(() => {
277
369
  setListEntranceAnimation(false);
278
- clearChat();
279
- setUserListCtx(chatReturnCtx);
280
- setScreen('user-list');
281
- }, [clearChat, chatReturnCtx]);
370
+ if (activeUser) { const roomId = [activeUser.uid, viewerUidForSocket].sort().join('_'); leaveRoom(roomId); }
371
+ clearChat(); setUserListCtx(chatReturnCtx); setScreen('user-list');
372
+ }, [clearChat, chatReturnCtx, activeUser, viewerUidForSocket, leaveRoom]);
282
373
 
283
374
  const handleOpenTicket = useCallback((id: string) => {
284
- setListEntranceAnimation(false);
285
- setViewingTicketId(id);
286
- setScreen('ticket-detail');
287
- setActiveTab('tickets');
375
+ setListEntranceAnimation(false); setViewingTicketId(id); setScreen('ticket-detail'); setActiveTab('tickets');
288
376
  }, []);
289
377
 
290
378
  const handleTabChange = useCallback((tab: BottomTab) => {
291
- setListEntranceAnimation(false);
292
- setActiveTab(tab);
379
+ setListEntranceAnimation(false); setActiveTab(tab);
293
380
  setScreen(tab === 'home' ? 'home' : tab === 'chats' ? 'recent-chats' : 'tickets');
294
381
  }, []);
295
382
 
@@ -299,85 +386,82 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme, viewe
299
386
  return () => window.clearTimeout(t);
300
387
  }, [listEntranceAnimation]);
301
388
 
302
- /* ── Block/Unblock ───────────────────────────────────────────────────── */
389
+ // ── Block / Unblock ───────────────────────────────────────────────────────
303
390
  const handleBlock = useCallback(() => {
304
391
  if (!activeUser) return;
305
392
  setBlockedUids(prev => [...prev, activeUser.uid]);
306
- clearChat();
307
- setScreen('block-list');
308
- setActiveTab('home');
309
- }, [activeUser, clearChat]);
393
+ emitBlock(activeUser.uid);
394
+ clearChat(); setScreen('block-list'); setActiveTab('home');
395
+ }, [activeUser, clearChat, emitBlock]);
310
396
 
311
397
  const handleUnblock = useCallback((uid: string) => {
312
398
  setBlockedUids(prev => prev.filter(id => id !== uid));
313
- }, []);
399
+ emitUnblock(uid);
400
+ }, [emitUnblock]);
314
401
 
315
- /* ── Tickets ─────────────────────────────────────────────────────────── */
402
+ // ── Tickets ───────────────────────────────────────────────────────────────
316
403
  const handleRaiseTicket = useCallback((title: string, desc: string, priority: Ticket['priority']) => {
317
404
  const t: Ticket = {
318
- id: `TKT-${String(Date.now()).slice(-4)}`,
319
- title, description: desc, status: 'open', priority,
320
- createdAt: new Date().toISOString(),
321
- updatedAt: new Date().toISOString(),
322
- assignedTo: null,
405
+ id: `TKT-${String(Date.now()).slice(-4)}`, title, description: desc, status: 'open', priority,
406
+ createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), assignedTo: null,
323
407
  };
324
408
  setTickets(prev => [...prev, t]);
325
- setViewingTicketId(t.id);
326
- setScreen('ticket-detail');
327
- setActiveTab('tickets');
328
- }, []);
409
+ setViewingTicketId(t.id); setScreen('ticket-detail'); setActiveTab('tickets');
410
+ emitTicketCreate({ title, description: desc, priority, createdBy: viewerUidForSocket });
411
+ }, [emitTicketCreate, viewerUidForSocket]);
329
412
 
330
- /* ── Pause sync back into recent chats ──────────────────────────────── */
413
+ // ── Pause sync ────────────────────────────────────────────────────────────
331
414
  const handleTogglePause = useCallback(() => {
332
- togglePause();
333
- if (activeUser) {
334
- setRecentChats(prev => prev.map(r => r.user.uid === activeUser.uid ? { ...r, isPaused: !isPaused } : r));
335
- }
336
- }, [togglePause, activeUser, isPaused]);
415
+ _togglePause();
416
+ if (activeUser) setRecentChats(prev => prev.map(r =>
417
+ r.user.uid === activeUser.uid ? { ...r, isPaused: !isPaused } : r
418
+ ));
419
+ }, [_togglePause, activeUser, isPaused]);
337
420
 
338
- /* ── Call ────────────────────────────────────────────────────────────── */
421
+ // ── Call ──────────────────────────────────────────────────────────────────
339
422
  const handleStartCall = useCallback((withVideo: boolean) => {
340
- if (!activeUser) return;
341
- startCall(activeUser, withVideo);
342
- setScreen('call');
343
- }, [activeUser, startCall]);
344
-
345
- const handleEndCall = useCallback(() => {
346
- endCall();
347
- setCallMinimized(false);
348
- setScreen('chat');
349
- }, [endCall]);
423
+ if (!activeUser) return; _startCall(activeUser, withVideo); setScreen('call');
424
+ }, [activeUser, _startCall]);
350
425
 
351
- const minimizeCall = useCallback(() => {
352
- setCallMinimized(true);
353
- closeDrawer();
354
- }, [closeDrawer]);
355
-
356
- /* ── Derived ─────────────────────────────────────────────────────────── */
357
- const isBlocked = activeUser ? blockedUids.includes(activeUser.uid) : false;
426
+ const handleEndCall = useCallback(() => { _endCall(); setCallMinimized(false); setScreen('chat'); }, [_endCall]);
427
+ const minimizeCall = useCallback(() => { setCallMinimized(true); closeDrawer(); }, [closeDrawer]);
358
428
 
429
+ // ── Transfer ──────────────────────────────────────────────────────────────
359
430
  const widgetConfig = useMemo(() => {
360
431
  if (!data?.widget) return undefined;
361
432
  const w = { ...data.widget };
362
- if (viewer) {
363
- w.viewerUid = viewer.uid;
364
- w.viewerName = viewer.name;
365
- w.viewerType = viewer.type;
366
- if (viewer.projectId?.trim()) w.viewerProjectId = viewer.projectId.trim();
367
- }
433
+ if (viewer) { w.viewerUid = viewer.uid; w.viewerName = viewer.name; w.viewerType = viewer.type; if (viewer.projectId?.trim()) w.viewerProjectId = viewer.projectId.trim(); }
368
434
  return w;
369
435
  }, [data?.widget, viewer]);
370
436
 
371
- const primaryColor = theme.primaryColor;
437
+ const handleTransferToDeveloper = useCallback((dev: ChatUser) => {
438
+ if (!activeUser || !widgetConfig) return;
439
+ const agent = widgetConfig.viewerName?.trim() || 'Agent';
440
+ const roomId = [activeUser.uid, viewerUidForSocket].sort().join('_');
441
+ const transferNote: ChatMessage = {
442
+ id: `tr_${Date.now()}`, senderId: 'me', receiverId: dev.uid,
443
+ text: `— ${agent} transferred this conversation from ${activeUser.name} to ${dev.name} —`,
444
+ timestamp: new Date().toISOString(), type: 'text', status: 'sent',
445
+ };
446
+ emitTransfer(roomId, dev.uid, transferNote.text);
447
+ selectUser(dev, [...messages, transferNote]);
448
+ }, [activeUser, messages, selectUser, widgetConfig, viewerUidForSocket, emitTransfer]);
449
+
450
+ const handleAddParticipant = useCallback((uid: string) => {
451
+ if (!activeUser) return;
452
+ emitAddParticipant([activeUser.uid, viewerUidForSocket].sort().join('_'), uid);
453
+ }, [activeUser, viewerUidForSocket, emitAddParticipant]);
454
+
455
+ // ── Derived ───────────────────────────────────────────────────────────────
456
+ const isBlocked = activeUser ? blockedUids.includes(activeUser.uid) : false;
457
+ const primaryColor = headerBg;
372
458
 
373
- /** All developers are listed; only end-`user` rows are filtered by `viewer.projectId`. */
374
459
  const allUsers = useMemo(() => {
375
460
  if (!data) return [];
376
- const pid = viewer?.projectId?.trim();
461
+ const pid = viewer?.projectId?.trim();
377
462
  const devs = data.developers ?? [];
378
463
  if (!pid) return [...devs, ...data.users];
379
- const usersInProject = data.users.filter(u => u.project === pid);
380
- return [...devs, ...usersInProject];
464
+ return [...devs, ...data.users.filter(u => u.project === pid)];
381
465
  }, [data, viewer?.projectId]);
382
466
 
383
467
  const effectiveViewerBlocked = useMemo(() => {
@@ -389,435 +473,372 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme, viewe
389
473
  return rec?.viewerBlocked === true;
390
474
  }, [widgetConfig, viewer?.uid, data]);
391
475
 
392
- const viewerIsDev = widgetConfig?.viewerType === 'developer';
393
- const viewerUid = widgetConfig?.viewerUid;
476
+ const viewerIsDev = widgetConfig?.viewerType === 'developer';
477
+ const viewerUid = widgetConfig?.viewerUid;
394
478
 
395
479
  const filteredUsers = screen === 'user-list'
396
480
  ? allUsers.filter(u => {
397
- 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;
403
- }
481
+ if (userListCtx === 'support') return viewerIsDev ? u.type === 'user' : u.type === 'developer';
482
+ if (viewerIsDev) return u.type === 'developer' && u.uid !== viewerUid;
404
483
  return u.type === 'user';
405
484
  })
406
485
  : [];
407
486
 
408
487
  const otherDevelopers = useMemo(
409
488
  () => allUsers.filter(u => u.type === 'developer' && u.uid !== viewerUid),
410
- [allUsers, viewerUid],
411
- );
412
- const blockedUsers = allUsers.filter(u => blockedUids.includes(u.uid));
413
-
414
- const totalUnread = useMemo(
415
- () => recentChats.reduce((sum, c) => sum + Math.max(0, c.unread ?? 0), 0),
416
- [recentChats],
489
+ [allUsers, viewerUid]
417
490
  );
418
-
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',
491
+ const blockedUsers = allUsers.filter(u => blockedUids.includes(u.uid));
492
+ const totalUnread = useMemo(() => recentChats.reduce((s, c) => s + Math.max(0, c.unread ?? 0), 0), [recentChats]);
493
+
494
+ // ── Panel geometry from display mode ──────────────────────────────────────
495
+ const geo = useMemo(() =>
496
+ getPanelGeometry(displayMode, theme.buttonPosition ?? 'bottom-right', ajaxterSettings?.maximized),
497
+ [displayMode, theme.buttonPosition, ajaxterSettings?.maximized]);
498
+
499
+ // Button geometry from Ajaxter minimized config
500
+ const btnStyle = useMemo((): React.CSSProperties => {
501
+ const pos = theme.buttonPosition === 'bottom-left' ? { left: 24, right: 'auto' } : { right: 24, left: 'auto' };
502
+ if (btnType === 'round') {
503
+ return { position:'fixed', bottom:24, zIndex:9999, ...pos,
504
+ width:56, height:56, borderRadius:'50%', padding:0,
505
+ display:'flex', alignItems:'center', justifyContent:'center',
506
+ backgroundColor: theme.buttonColor, color: theme.buttonTextColor,
507
+ border:'none', cursor:'pointer', boxShadow:`0 8px 28px ${theme.buttonColor}55`,
508
+ animation:'cw-btnPop 0.4s cubic-bezier(0.34,1.56,0.64,1)',
509
+ };
510
+ }
511
+ // 'slide' type (pill)
512
+ const w = minimizedDesktop?.width ?? 126;
513
+ const h = minimizedDesktop?.height ?? 40;
514
+ return { position:'fixed', bottom:24, zIndex:9999, ...pos,
515
+ width:w, height:h,
516
+ borderRadius: `${minimizedDesktop?.borderRadiusTop ?? 150}px ${minimizedDesktop?.borderRadiusBottom ?? 140}px ${minimizedDesktop?.borderRadiusBottom ?? 140}px ${minimizedDesktop?.borderRadiusTop ?? 150}px`,
517
+ display:'flex', alignItems:'center', justifyContent:'center', gap:8, padding:'0 16px',
518
+ backgroundColor: theme.buttonColor, color: theme.buttonTextColor,
519
+ border:'none', cursor:'pointer', fontSize:13, fontWeight:700,
520
+ boxShadow:`0 8px 28px ${theme.buttonColor}55`,
521
+ animation:'cw-btnPop 0.4s cubic-bezier(0.34,1.56,0.64,1)',
430
522
  };
431
- selectUser(dev, [...messages, transferNote]);
432
- }, [activeUser, messages, selectUser, widgetConfig]);
523
+ }, [btnType, theme, minimizedDesktop]);
433
524
 
434
- /* Position */
435
525
  const posStyle: React.CSSProperties = theme.buttonPosition === 'bottom-left'
436
- ? { left: 24, right: 'auto' }
437
- : { right: 24, left: 'auto' };
438
-
439
- /* No radius on top-left / bottom-left; left-docked panel keeps inner TR/BR curve */
440
- const drawerPosStyle: React.CSSProperties =
441
- 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
- };
526
+ ? { left: 24, right: 'auto' } : { right: 24, left: 'auto' };
454
527
 
455
- /* ── Don't render until mounted (SSR safe) ──────────────────────────── */
456
528
  if (!mounted) return null;
457
529
 
530
+ // Inline mode — always "open", no floating button
531
+ if (displayMode === 'inline') {
532
+ return (
533
+ <ErrorBoundary primaryColor={primaryColor}>
534
+ <style>{GLOBAL_STYLES(agentBg, visitorBg, headerBg)}</style>
535
+ {widgetConfig && !cfgLoading && !cfgError && (
536
+ <WidgetContent
537
+ {...contentProps()} widgetConfig={widgetConfig} primaryColor={primaryColor}
538
+ agentBg={agentBg} visitorBg={visitorBg}
539
+ panelStyle={geo.panel} isClosable={false}
540
+ />
541
+ )}
542
+ </ErrorBoundary>
543
+ );
544
+ }
545
+
546
+ // Helper to collect all props for WidgetContent
547
+ function contentProps() {
548
+ return {
549
+ screen, activeTab, userListCtx, viewingTicketId, listEntranceAnimation,
550
+ tickets, recentChats, blockedUsers, blockedUids, filteredUsers,
551
+ otherDevelopers, allUsers, messages, activeUser, isPaused, isReported,
552
+ isBlocked, callSession, localVideoRef, remoteVideoRef,
553
+ effectiveViewerBlocked, permissionsOk, messageSoundEnabled, typingUsers,
554
+ viewerIsDev, apiKey, totalUnread,
555
+ onClose: closeDrawer,
556
+ onTabChange: handleTabChange, onCardClick: handleCardClick,
557
+ onNavFromMenu: handleNavFromMenu, onSelectUser: handleSelectUser,
558
+ onBackFromChat: handleBackFromChat, onOpenTicket: handleOpenTicket,
559
+ onSend: sendMessage, onTogglePause: handleTogglePause, onReport: _reportChat,
560
+ onBlock: handleBlock, onUnblock: handleUnblock,
561
+ onStartCall: handleStartCall, onEndCall: handleEndCall, onMinimizeCall: minimizeCall,
562
+ onToggleMute: toggleMute, onToggleCamera: toggleCamera,
563
+ onRaiseTicket: handleRaiseTicket, onTransfer: handleTransferToDeveloper,
564
+ onAddParticipant: handleAddParticipant, onToggleMessageSound: toggleMessageSound,
565
+ setScreen, setActiveTab, setViewingTicketId, setListEntranceAnimation,
566
+ listCtxForUser,
567
+ };
568
+ }
569
+
458
570
  return (
459
- <>
460
- {/* ── Global styles ── */}
461
- <style>{`
462
- @import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700;800&display=swap');
463
-
464
- .cw-root * { box-sizing: border-box; font-family: 'DM Sans', 'Segoe UI', sans-serif; }
465
-
466
- @keyframes cw-slideInRight { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
467
- @keyframes cw-slideOutRight { from { transform: translateX(0); opacity: 1; } to { transform: translateX(100%); opacity: 0; } }
468
- @keyframes cw-slideInLeft { from { transform: translateX(-100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
469
- @keyframes cw-slideOutLeft { from { transform: translateX(0); opacity: 1; } to { transform: translateX(-100%); opacity: 0; } }
470
- @keyframes cw-fadeUp { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
471
- @keyframes cw-slideIn { from { opacity: 0; transform: translateX(18px); } to { opacity: 1; transform: translateX(0); } }
472
- @keyframes cw-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
473
- @keyframes cw-btnPop { from { transform: scale(0.8); opacity: 0; } to { transform: scale(1); opacity: 1; } }
474
-
475
- .cw-scroll::-webkit-scrollbar { width: 4px; }
476
- .cw-scroll::-webkit-scrollbar-track { background: transparent; }
477
- .cw-scroll::-webkit-scrollbar-thumb { background: #e0e0e0; border-radius: 4px; }
478
-
479
- .cw-drawer-enter { animation: ${theme.buttonPosition === 'bottom-left' ? 'cw-slideInLeft' : 'cw-slideInRight'} 0.32s cubic-bezier(0.22,1,0.36,1) both; }
480
- .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
- }
490
- `}</style>
571
+ <ErrorBoundary primaryColor={primaryColor}>
572
+ <style>{GLOBAL_STYLES(agentBg, visitorBg, headerBg)}</style>
573
+
574
+ {/* Socket status indicator */}
575
+ {socketStatus !== 'connected' && socketStatus !== 'disconnected' && isOpen && (
576
+ <div style={{
577
+ position:'fixed', bottom:80, ...posStyle, zIndex:9996,
578
+ background: socketStatus === 'connecting' ? '#f59e0b' : '#ef4444',
579
+ color:'#fff', fontSize:11, fontWeight:700,
580
+ padding:'3px 10px', borderRadius:20,
581
+ }}>
582
+ {socketStatus === 'connecting' ? '● Connecting…' : '● Offline'}
583
+ </div>
584
+ )}
491
585
 
492
- {/* ── Minimized call bar (drawer closed, call still active) ── */}
586
+ {/* Mini call bar */}
493
587
  {!isOpen && callMinimized && callInProgress && callSession.peer && (
494
- <MiniCallBar
495
- session={callSession}
496
- primaryColor={primaryColor}
497
- buttonPosition={theme.buttonPosition}
498
- onExpand={openDrawer}
499
- onEnd={handleEndCall}
500
- />
588
+ <MiniCallBar session={callSession} primaryColor={primaryColor}
589
+ buttonPosition={theme.buttonPosition} onExpand={openDrawer} onEnd={handleEndCall} />
501
590
  )}
502
591
 
503
- {/* ── Floating Button (unread badge + tooltip when closed) ── */}
592
+ {/* Floating button */}
504
593
  {!isOpen && (
505
- <button
506
- className="cw-root"
507
- type="button"
508
- onClick={openDrawer}
594
+ <button className="cw-root" type="button" onClick={openDrawer}
509
595
  aria-label={totalUnread > 0 ? `${theme.buttonLabel}, ${totalUnread} unread` : theme.buttonLabel}
510
- title={totalUnread > 0 ? `${totalUnread} unread message${totalUnread === 1 ? '' : 's'}` : theme.buttonLabel}
511
- style={{
512
- position: 'fixed', bottom: 24, zIndex: 9999,
513
- ...posStyle,
514
- display: 'flex', alignItems: 'center', gap: 10,
515
- padding: '13px 22px',
516
- backgroundColor: theme.buttonColor,
517
- color: theme.buttonTextColor,
518
- border: 'none', borderRadius: 50,
519
- cursor: 'pointer', fontSize: 15, fontWeight: 700,
520
- boxShadow: `0 8px 28px ${theme.buttonColor}55`,
521
- animation: 'cw-btnPop 0.4s cubic-bezier(0.34,1.56,0.64,1)',
522
- transition: 'transform 0.2s, box-shadow 0.2s',
523
- }}
524
- onMouseEnter={e => {
525
- (e.currentTarget as HTMLElement).style.transform = 'scale(1.06) translateY(-2px)';
526
- (e.currentTarget as HTMLElement).style.boxShadow = `0 14px 36px ${theme.buttonColor}66`;
527
- }}
528
- onMouseLeave={e => {
529
- (e.currentTarget as HTMLElement).style.transform = 'scale(1)';
530
- (e.currentTarget as HTMLElement).style.boxShadow = `0 8px 28px ${theme.buttonColor}55`;
531
- }}
596
+ style={btnStyle}
597
+ onMouseEnter={e => { (e.currentTarget as HTMLElement).style.transform = 'scale(1.06) translateY(-2px)'; }}
598
+ onMouseLeave={e => { (e.currentTarget as HTMLElement).style.transform = 'scale(1)'; }}
532
599
  >
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>
562
- <span>{theme.buttonLabel}</span>
600
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none">
601
+ <path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"
602
+ stroke={theme.buttonTextColor} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
603
+ </svg>
604
+ {btnType !== 'round' && <span>{theme.buttonLabel}</span>}
605
+ {totalUnread > 0 && (
606
+ <span style={{ position:'absolute', top:-6, right:-6,
607
+ minWidth:18, height:18, padding:'0 4px', borderRadius:999,
608
+ background:'#ef4444', color:'#fff', fontSize:10, fontWeight:800,
609
+ lineHeight:'18px', textAlign:'center', border:'2px solid #fff' }}>
610
+ {totalUnread > 99 ? '99+' : totalUnread}
611
+ </span>
612
+ )}
563
613
  </button>
564
614
  )}
565
615
 
566
- {/* ── Backdrop (visual only — does not close widget on click) ── */}
567
- {isOpen && (
568
- <div
569
- aria-hidden
570
- style={{
571
- position: 'fixed', inset: 0, zIndex: 9997,
572
- backgroundColor: 'rgba(0,0,0,0.35)',
573
- opacity: closing ? 0 : 1,
574
- transition: 'opacity 0.3s',
575
- }}
576
- />
616
+ {/* Backdrop */}
617
+ {isOpen && geo.backdrop && (
618
+ <div aria-hidden style={{
619
+ ...geo.backdrop,
620
+ opacity: closing ? 0 : 1, transition: 'opacity 0.3s',
621
+ }} />
577
622
  )}
578
623
 
579
- {/* ── Drawer / Slider ── */}
624
+ {/* Panel */}
580
625
  {isOpen && (
581
626
  <div
582
- className={`cw-root cw-drawer-panel ${closing ? 'cw-drawer-exit' : 'cw-drawer-enter'}`}
583
- style={{
584
- position: 'fixed',
585
- top: 0,
586
- bottom: 0,
587
- ...drawerPosStyle,
588
- zIndex: 9998,
589
- backgroundColor: '#fff',
590
- boxShadow: theme.buttonPosition === 'bottom-left'
591
- ? '4px 0 40px rgba(0,0,0,0.18)'
592
- : '-4px 0 40px rgba(0,0,0,0.18)',
593
- display: 'flex',
594
- flexDirection: 'column',
595
- overflow: 'hidden',
596
- }}
627
+ className={`cw-root ${closing ? geo.exitClass : geo.enterClass}`}
628
+ style={geo.panel}
597
629
  >
598
- {/* ── Loading state ── */}
599
630
  {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>
631
+ <div style={{ display:'flex', flexDirection:'column', alignItems:'center', justifyContent:'center', height:'100%', gap:16 }}>
632
+ <div style={{ width:40, height:40, borderRadius:'50%', border:`3px solid ${primaryColor}30`, borderTopColor:primaryColor, animation:'spin 0.8s linear infinite' }} />
633
+ <p style={{ fontSize:14, color:'#7b8fa1' }}>Loading chat…</p>
609
634
  </div>
610
635
  )}
611
-
612
- {/* ── Error state ── */}
613
636
  {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>
637
+ <div style={{ display:'flex', flexDirection:'column', alignItems:'center', justifyContent:'center', height:'100%', gap:12, padding:32, textAlign:'center' }}>
638
+ <div style={{ fontSize:40 }}>⚠️</div>
639
+ <p style={{ fontWeight:700, color:'#1a2332' }}>Could not load chat</p>
640
+ <p style={{ fontSize:13, color:'#7b8fa1' }}>{cfgError}</p>
641
+ <button onClick={closeDrawer} style={{ padding:'9px 20px', borderRadius:10, border:'none', background:primaryColor, color:'#fff', cursor:'pointer', fontWeight:700 }}>Close</button>
619
642
  </div>
620
643
  )}
621
-
622
- {/* ── Main content ── */}
623
644
  {!cfgLoading && !cfgError && widgetConfig && (
624
- <>
625
- {/* Resize + Close controls — hidden on blocked screen (Close is in-panel) */}
626
- {screen !== 'chat' && screen !== 'call' && !effectiveViewerBlocked && (
627
- <div style={{
628
- position: 'absolute', top: 12,
629
- right: theme.buttonPosition === 'bottom-left' ? 'auto' : 12,
630
- left: theme.buttonPosition === 'bottom-left' ? 12 : 'auto',
631
- zIndex: 20, display: 'flex', gap: 6,
632
- }}>
633
- <CornerBtn onClick={closeDrawer} title="Close">
634
- <svg width="12" height="12" viewBox="0 0 24 24" fill="none">
635
- <path d="M18 6L6 18M6 6l12 12" stroke="#fff" strokeWidth="2.5" strokeLinecap="round"/>
636
- </svg>
637
- </CornerBtn>
638
- </div>
639
- )}
640
-
641
- {/* ── MAINTENANCE ── */}
642
- {widgetConfig.status === 'MAINTENANCE' && (
643
- <MaintenanceView primaryColor={primaryColor} />
644
- )}
645
-
646
- {/* ── DISABLED ── */}
647
- {widgetConfig.status === 'DISABLE' && (
648
- <div style={{ display:'flex',flexDirection:'column',alignItems:'center',justifyContent:'center',height:'100%',padding:32,textAlign:'center',gap:12 }}>
649
- <div style={{ fontSize:40 }}>🔒</div>
650
- <p style={{ fontWeight:700,color:'#1a2332' }}>Chat is disabled</p>
651
- <button onClick={closeDrawer} style={{ padding:'9px 20px',borderRadius:10,border:'none',background:primaryColor,color:'#fff',cursor:'pointer',fontWeight:700 }}>Close</button>
652
- </div>
653
- )}
654
-
655
- {/* ── ACTIVE: viewer spam-blocked (no chat/tickets UI) ── */}
656
- {widgetConfig.status === 'ACTIVE' && effectiveViewerBlocked && (
657
- <ViewerBlockedScreen config={widgetConfig} apiKey={apiKey} onClose={closeDrawer} />
658
- )}
659
-
660
- {/* ── ACTIVE: microphone, location, screen share required ── */}
661
- {widgetConfig.status === 'ACTIVE' && !effectiveViewerBlocked && !permissionsOk && (
662
- <PermissionsGateScreen
663
- primaryColor={primaryColor}
664
- widgetId={widgetConfig.id}
665
- onGranted={() => setPermissionsOk(true)}
666
- />
667
- )}
668
-
669
- {/* ── ACTIVE ── */}
670
- {widgetConfig.status === 'ACTIVE' && !effectiveViewerBlocked && permissionsOk && (
671
- <div className="cw-scroll" style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
672
-
673
- {screen === 'home' && (
674
- <HomeScreen
675
- config={widgetConfig}
676
- apiKey={apiKey}
677
- onNavigate={handleCardClick}
678
- onOpenTicket={handleOpenTicket}
679
- tickets={tickets}
680
- />
681
- )}
682
-
683
- {screen === 'user-list' && (
684
- <UserListScreen
685
- context={userListCtx}
686
- users={filteredUsers}
687
- primaryColor={primaryColor}
688
- viewerType={widgetConfig.viewerType ?? 'user'}
689
- onBack={() => { setListEntranceAnimation(false); setScreen('home'); }}
690
- onSelectUser={handleSelectUser}
691
- onBlockList={userListCtx === 'conversation' ? () => setScreen('block-list') : undefined}
692
- useHomeHeader={userListCtx === 'support' && widgetConfig.viewerType !== 'developer'}
693
- animateEntrance={listEntranceAnimation}
694
- />
695
- )}
696
-
697
- {screen === 'chat' && activeUser && (
698
- <ChatScreen
699
- activeUser={activeUser}
700
- messages={messages}
701
- config={widgetConfig}
702
- isPaused={isPaused}
703
- isReported={isReported}
704
- isBlocked={isBlocked}
705
- onSend={sendMessage}
706
- onBack={handleBackFromChat}
707
- onClose={closeDrawer}
708
- onTogglePause={handleTogglePause}
709
- onReport={reportChat}
710
- onBlock={handleBlock}
711
- onStartCall={handleStartCall}
712
- onNavAction={handleNavFromMenu}
713
- otherDevelopers={otherDevelopers}
714
- onTransferToDeveloper={handleTransferToDeveloper}
715
- messageSoundEnabled={messageSoundEnabled}
716
- onToggleMessageSound={toggleMessageSound}
717
- />
718
- )}
719
-
720
- {screen === 'call' && callSession.peer && (
721
- <CallScreen
722
- session={callSession}
723
- localVideoRef={localVideoRef}
724
- remoteVideoRef={remoteVideoRef}
725
- onEnd={handleEndCall}
726
- onToggleMute={toggleMute}
727
- onToggleCamera={toggleCamera}
728
- primaryColor={primaryColor}
729
- onMinimize={minimizeCall}
730
- />
731
- )}
732
-
733
- {screen === 'recent-chats' && (
734
- <RecentChatsScreen
735
- chats={recentChats}
736
- config={widgetConfig}
737
- onSelectChat={u => handleSelectUser(u, listCtxForUser(u, viewerIsDev))}
738
- animateEntrance={listEntranceAnimation}
739
- />
740
- )}
741
-
742
- {screen === 'tickets' && (
743
- <TicketScreen
744
- tickets={tickets}
745
- config={widgetConfig}
746
- onNewTicket={() => { setListEntranceAnimation(false); setScreen('ticket-new'); }}
747
- onSelectTicket={id => {
748
- setListEntranceAnimation(false);
749
- setViewingTicketId(id);
750
- setScreen('ticket-detail');
751
- }}
752
- animateEntrance={listEntranceAnimation}
753
- />
754
- )}
755
-
756
- {screen === 'ticket-new' && (
757
- <TicketFormScreen
758
- config={widgetConfig}
759
- onSubmit={handleRaiseTicket}
760
- onCancel={() => setScreen('tickets')}
761
- />
762
- )}
763
-
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
- )}
776
-
777
- {screen === 'block-list' && (
778
- <BlockListScreen
779
- blockedUsers={blockedUsers}
780
- config={widgetConfig}
781
- onUnblock={handleUnblock}
782
- onBack={() => { setScreen('home'); setActiveTab('home'); }}
783
- />
784
- )}
785
- </div>
786
- )}
787
-
788
- {/* ── Bottom Tabs (hidden during chat/call/user-list/block-list) ── */}
789
- {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
- />
803
- )}
804
- </>
645
+ <ErrorBoundary primaryColor={primaryColor}>
646
+ <WidgetContent
647
+ {...contentProps()} widgetConfig={widgetConfig} primaryColor={primaryColor}
648
+ agentBg={agentBg} visitorBg={visitorBg}
649
+ panelStyle={{}} isClosable={true}
650
+ />
651
+ </ErrorBoundary>
805
652
  )}
806
653
  </div>
807
654
  )}
808
- </>
655
+ </ErrorBoundary>
809
656
  );
810
657
  };
811
658
 
812
659
  export default ChatWidget;
813
660
 
814
- /* ── Tiny corner button ────────────────────────────────────────────────────── */
815
- const CornerBtn: React.FC<{ onClick: () => void; title: string; children: React.ReactNode }> = ({ onClick, title, children }) => (
816
- <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',
820
- }}>
821
- {children}
822
- </button>
823
- );
661
+ // ─── WidgetContent extracted so inline mode can reuse ───────────────────────
662
+ const WidgetContent: React.FC<Record<string, unknown>> = (props: Record<string, unknown>) => {
663
+ const {
664
+ screen, activeTab, userListCtx, viewingTicketId, listEntranceAnimation,
665
+ tickets, recentChats, blockedUsers, filteredUsers, otherDevelopers,
666
+ messages, activeUser, isPaused, isReported, isBlocked,
667
+ callSession, localVideoRef, remoteVideoRef,
668
+ effectiveViewerBlocked, permissionsOk, messageSoundEnabled,
669
+ viewerIsDev, apiKey, widgetConfig, primaryColor, agentBg, visitorBg,
670
+ isClosable,
671
+ onClose, onTabChange, onCardClick, onNavFromMenu, onSelectUser, onBackFromChat,
672
+ onOpenTicket, onSend, onTogglePause, onReport, onBlock, onUnblock,
673
+ onStartCall, onEndCall, onMinimizeCall, onToggleMute, onToggleCamera,
674
+ onRaiseTicket, onTransfer, onToggleMessageSound,
675
+ setScreen, setActiveTab, setViewingTicketId, setListEntranceAnimation,
676
+ listCtxForUser,
677
+ } = props as unknown as {
678
+ screen: Screen; activeTab: BottomTab; userListCtx: UserListContext;
679
+ viewingTicketId: string | null; listEntranceAnimation: boolean;
680
+ tickets: Ticket[]; recentChats: RecentChat[]; blockedUsers: ChatUser[];
681
+ filteredUsers: ChatUser[]; otherDevelopers: ChatUser[];
682
+ messages: ChatMessage[]; activeUser: ChatUser | null;
683
+ isPaused: boolean; isReported: boolean; isBlocked: boolean;
684
+ callSession: import('../types').CallSession;
685
+ localVideoRef: React.RefObject<HTMLVideoElement | null>;
686
+ remoteVideoRef: React.RefObject<HTMLVideoElement | null>;
687
+ effectiveViewerBlocked: boolean; permissionsOk: boolean; messageSoundEnabled: boolean;
688
+ viewerIsDev: boolean; apiKey: string; widgetConfig: import('../types').WidgetConfig;
689
+ primaryColor: string; agentBg: string; visitorBg: string; isClosable: boolean;
690
+ onClose: () => void; onTabChange: (t: BottomTab) => void;
691
+ onCardClick: (ctx: UserListContext | 'ticket', opts?: { fromMenu?: boolean }) => void;
692
+ onNavFromMenu: (ctx: UserListContext | 'ticket') => void;
693
+ onSelectUser: (u: ChatUser, ctx?: UserListContext) => void;
694
+ onBackFromChat: () => void; onOpenTicket: (id: string) => void;
695
+ onSend: (text: string, type?: ChatMessage['type'], extra?: Partial<ChatMessage>) => void;
696
+ onTogglePause: () => void; onReport: () => void; onBlock: () => void;
697
+ onUnblock: (uid: string) => void; onStartCall: (v: boolean) => void;
698
+ onEndCall: () => void; onMinimizeCall: () => void;
699
+ onToggleMute: () => void; onToggleCamera: () => void;
700
+ onRaiseTicket: (t: string, d: string, p: Ticket['priority']) => void;
701
+ onTransfer: (dev: ChatUser) => void; onToggleMessageSound: (e: boolean) => void;
702
+ setScreen: (s: Screen) => void; setActiveTab: (t: BottomTab) => void;
703
+ setViewingTicketId: (id: string | null) => void;
704
+ setListEntranceAnimation: (v: boolean) => void;
705
+ listCtxForUser: (u: ChatUser, isDev: boolean) => UserListContext;
706
+ };
707
+
708
+ const config = widgetConfig;
709
+ const callInProgress = callSession.state === 'calling' || callSession.state === 'connected';
710
+
711
+ return (
712
+ <>
713
+ {/* Close button */}
714
+ {isClosable && screen !== 'chat' && screen !== 'call' && !effectiveViewerBlocked && (
715
+ <div style={{ position:'absolute', top:12, right:12, zIndex:20 }}>
716
+ <button onClick={onClose} title="Close" style={{
717
+ width:26, height:26, borderRadius:'50%', background:'rgba(0,0,0,0.22)', border:'none',
718
+ display:'flex', alignItems:'center', justifyContent:'center', cursor:'pointer',
719
+ }}>
720
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none">
721
+ <path d="M18 6L6 18M6 6l12 12" stroke="#fff" strokeWidth="2.5" strokeLinecap="round"/>
722
+ </svg>
723
+ </button>
724
+ </div>
725
+ )}
726
+
727
+ {config.status === 'MAINTENANCE' && <MaintenanceView primaryColor={primaryColor} />}
728
+
729
+ {config.status === 'DISABLE' && (
730
+ <div style={{ display:'flex',flexDirection:'column',alignItems:'center',justifyContent:'center',height:'100%',padding:32,textAlign:'center',gap:12 }}>
731
+ <div style={{ fontSize:40 }}>🔒</div>
732
+ <p style={{ fontWeight:700, color:'#1a2332' }}>Chat is disabled</p>
733
+ {isClosable && <button onClick={onClose} style={{ padding:'9px 20px',borderRadius:10,border:'none',background:primaryColor,color:'#fff',cursor:'pointer',fontWeight:700 }}>Close</button>}
734
+ </div>
735
+ )}
736
+
737
+ {config.status === 'ACTIVE' && effectiveViewerBlocked && (
738
+ <ViewerBlockedScreen config={config} apiKey={apiKey} onClose={onClose} />
739
+ )}
740
+
741
+ {config.status === 'ACTIVE' && !effectiveViewerBlocked && !permissionsOk && (
742
+ <PermissionsGateScreen primaryColor={primaryColor} widgetId={config.id}
743
+ onGranted={() => { /* setPermissionsOk(true) — hoisted to parent */ }} />
744
+ )}
745
+
746
+ {config.status === 'ACTIVE' && !effectiveViewerBlocked && permissionsOk && (
747
+ <div className="cw-scroll" style={{ flex:1, display:'flex', flexDirection:'column', overflow:'hidden' }}>
748
+
749
+ {screen === 'home' && (
750
+ <HomeScreen config={config} apiKey={apiKey} onNavigate={onCardClick} onOpenTicket={onOpenTicket} tickets={tickets} />
751
+ )}
752
+ {screen === 'user-list' && (
753
+ <UserListScreen context={userListCtx} users={filteredUsers} primaryColor={primaryColor}
754
+ viewerType={config.viewerType ?? 'user'}
755
+ onBack={() => { setListEntranceAnimation(false); setScreen('home'); }}
756
+ onSelectUser={onSelectUser}
757
+ onBlockList={userListCtx === 'conversation' ? () => setScreen('block-list') : undefined}
758
+ useHomeHeader={userListCtx === 'support' && config.viewerType !== 'developer'}
759
+ animateEntrance={listEntranceAnimation} />
760
+ )}
761
+ {screen === 'chat' && activeUser && (
762
+ <ChatScreen activeUser={activeUser} messages={messages} config={config}
763
+ isPaused={isPaused} isReported={isReported} isBlocked={isBlocked}
764
+ onSend={onSend} onBack={onBackFromChat} onClose={onClose}
765
+ onTogglePause={onTogglePause} onReport={onReport} onBlock={onBlock}
766
+ onStartCall={onStartCall} onNavAction={onNavFromMenu}
767
+ otherDevelopers={otherDevelopers} onTransferToDeveloper={onTransfer}
768
+ messageSoundEnabled={messageSoundEnabled} onToggleMessageSound={onToggleMessageSound} />
769
+ )}
770
+ {screen === 'call' && callSession.peer && (
771
+ <CallScreen session={callSession} localVideoRef={localVideoRef} remoteVideoRef={remoteVideoRef}
772
+ onEnd={onEndCall} onToggleMute={onToggleMute} onToggleCamera={onToggleCamera}
773
+ primaryColor={primaryColor} onMinimize={onMinimizeCall} />
774
+ )}
775
+ {screen === 'recent-chats' && (
776
+ <RecentChatsScreen chats={recentChats} config={config}
777
+ onSelectChat={u => onSelectUser(u, listCtxForUser(u, viewerIsDev))}
778
+ animateEntrance={listEntranceAnimation} />
779
+ )}
780
+ {screen === 'tickets' && (
781
+ <TicketScreen tickets={tickets} config={config}
782
+ onNewTicket={() => { setListEntranceAnimation(false); setScreen('ticket-new'); }}
783
+ onSelectTicket={id => { setListEntranceAnimation(false); setViewingTicketId(id); setScreen('ticket-detail'); }}
784
+ animateEntrance={listEntranceAnimation} />
785
+ )}
786
+ {screen === 'ticket-new' && (
787
+ <TicketFormScreen config={config} onSubmit={onRaiseTicket} onCancel={() => setScreen('tickets')} />
788
+ )}
789
+ {screen === 'ticket-detail' && viewingTicketId && (() => {
790
+ const t = (tickets as Ticket[]).find(x => x.id === viewingTicketId);
791
+ return t ? (
792
+ <TicketDetailScreen ticket={t} config={config}
793
+ onBack={() => { setViewingTicketId(null); setScreen('tickets'); }} />
794
+ ) : null;
795
+ })()}
796
+ {screen === 'block-list' && (
797
+ <BlockListScreen blockedUsers={blockedUsers} config={config}
798
+ onUnblock={onUnblock} onBack={() => { setScreen('home'); setActiveTab('home'); }} />
799
+ )}
800
+ </div>
801
+ )}
802
+
803
+ {/* Bottom tabs */}
804
+ {config.status === 'ACTIVE' && !effectiveViewerBlocked && permissionsOk &&
805
+ !['chat','call','user-list','block-list','ticket-detail','ticket-new'].includes(screen) && (
806
+ <BottomTabs active={activeTab} onChange={onTabChange} primaryColor={primaryColor} />
807
+ )}
808
+ </>
809
+ );
810
+ };
811
+
812
+ // ─── Global CSS ───────────────────────────────────────────────────────────────
813
+ function GLOBAL_STYLES(agentBg: string, visitorBg: string, headerBg: string): string {
814
+ return `
815
+ @import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700;800&display=swap');
816
+ .cw-root * { box-sizing:border-box; font-family:'DM Sans','Segoe UI',sans-serif; }
817
+ @keyframes spin { to { transform: rotate(360deg); } }
818
+ @keyframes cw-btnPop { from { transform:scale(0.8);opacity:0; } to { transform:scale(1);opacity:1; } }
819
+ @keyframes cw-fadeUp { from { opacity:0;transform:translateY(10px); } to { opacity:1;transform:translateY(0); } }
820
+ @keyframes cw-slideIn { from { opacity:0;transform:translateX(18px); } to { opacity:1;transform:translateX(0); } }
821
+ @keyframes cw-pulse { 0%,100%{opacity:1} 50%{opacity:0.4} }
822
+ @keyframes cw-slideInRight { from{transform:translateX(100%);opacity:0} to{transform:translateX(0);opacity:1} }
823
+ @keyframes cw-slideOutRight { from{transform:translateX(0);opacity:1} to{transform:translateX(100%);opacity:0} }
824
+ @keyframes cw-slideInLeft { from{transform:translateX(-100%);opacity:0} to{transform:translateX(0);opacity:1} }
825
+ @keyframes cw-slideOutLeft { from{transform:translateX(0);opacity:1} to{transform:translateX(-100%);opacity:0} }
826
+ @keyframes cw-popupEnter { from{transform:translate(-50%,-50%) scale(0.9);opacity:0} to{transform:translate(-50%,-50%) scale(1);opacity:1} }
827
+ @keyframes cw-popupExit { from{transform:translate(-50%,-50%) scale(1);opacity:1} to{transform:translate(-50%,-50%) scale(0.9);opacity:0} }
828
+ .cw-slideInRight { animation:cw-slideInRight 0.32s cubic-bezier(0.22,1,0.36,1) both; }
829
+ .cw-slideOutRight { animation:cw-slideOutRight 0.28s cubic-bezier(0.55,0,1,0.45) both; }
830
+ .cw-slideInLeft { animation:cw-slideInLeft 0.32s cubic-bezier(0.22,1,0.36,1) both; }
831
+ .cw-slideOutLeft { animation:cw-slideOutLeft 0.28s cubic-bezier(0.55,0,1,0.45) both; }
832
+ .cw-popup-enter { animation:cw-popupEnter 0.28s cubic-bezier(0.22,1,0.36,1) both; }
833
+ .cw-popup-exit { animation:cw-popupExit 0.22s cubic-bezier(0.55,0,1,0.45) both; }
834
+ .cw-scroll::-webkit-scrollbar { width:4px; }
835
+ .cw-scroll::-webkit-scrollbar-track { background:transparent; }
836
+ .cw-scroll::-webkit-scrollbar-thumb { background:#e0e0e0; border-radius:4px; }
837
+ /* Ajaxter theme vars */
838
+ :root {
839
+ --cw-agent-bg: ${agentBg};
840
+ --cw-visitor-bg: ${visitorBg};
841
+ --cw-header-bg: ${headerBg};
842
+ }
843
+ `;
844
+ }