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