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.
@@ -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
- /** WebSocket server URL — override via env NEXT_PUBLIC_SOCKET_URL / REACT_APP_SOCKET_URL */
35
- function getSocketUrl(): string {
36
- if (typeof process !== 'undefined' && process.env) {
37
- return (
38
- process.env['NEXT_PUBLIC_SOCKET_URL'] ??
39
- process.env['REACT_APP_SOCKET_URL'] ??
40
- 'http://localhost:3005'
41
- );
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
- return 'http://localhost:3005';
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: data.widget.primaryColor,
57
- buttonLabel: data.widget.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
- const [isOpen, setIsOpen] = useState(false);
64
- const [closing, setClosing] = useState(false);
65
- const [callMinimized, setCallMinimized] = useState(false);
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, setPermissionsOk] = useState(false);
74
- const [socketStatus, setSocketStatus] = useState<'connecting'|'connected'|'disconnected'|'error'>('disconnected');
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[]>(data?.sampleTickets ?? []);
173
+ const [tickets, setTickets] = useState<Ticket[]>([]);
77
174
  const [recentChats, setRecentChats] = useState<RecentChat[]>([]);
78
- const [blockedUids, setBlockedUids] = useState<string[]>(data?.blockedUsers ?? []);
175
+ const [blockedUids, setBlockedUids] = useState<string[]>([]);
79
176
 
80
- /* Sync remote data into local state once loaded */
177
+ // Sync remote data once loaded
81
178
  useEffect(() => {
82
- if (data) {
83
- setTickets(data.sampleTickets);
84
- setBlockedUids(data.blockedUsers);
85
- const pid = viewer?.projectId?.trim();
86
- const devs = data.developers ?? [];
87
- const usr = pid ? (data.users ?? []).filter(u => u.project === pid) : (data.users ?? []);
88
- const all = [...devs, ...usr];
89
- const recents: RecentChat[] = Object.entries(data.sampleChats).map(([uid, msgs]) => {
90
- const user = all.find(u => u.uid === uid);
91
- if (!user || msgs.length === 0) return null;
92
- const last = msgs[msgs.length - 1];
93
- return {
94
- id: `rc_${uid}`,
95
- user,
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
- /* ── Socket setup ───────────────────────────────────────────────────── */
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: getSocketUrl(),
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
- // Sync pause state from remote
135
- setRecentChats(prev => prev.map(r =>
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
- /* ── Chat hook ──────────────────────────────────────────────────────── */
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) => { emitMessage(msg); },
177
- onEmitPause: (roomId, targetUid, paused) => { emitPauseToggle(roomId, targetUid, paused); },
178
- onEmitReport: (roomId) => { emitReport(roomId); },
257
+ onEmitMessage: (msg) => emitMessage(msg),
258
+ onEmitPause: (roomId, targetUid, paused) => emitPauseToggle(roomId, targetUid, paused),
259
+ onEmitReport: (roomId) => emitReport(roomId),
179
260
  });
180
261
 
181
- /* ── WebRTC hook ────────────────────────────────────────────────────── */
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: (offer, toUid, callId) => { emitCallOffer(offer, toUid, callId); },
188
- onAnswerReady: (answer, toUid, callId) => { emitCallAnswer(answer, toUid, callId); },
189
- onIceCandidateReady: (candidate, toUid) => { emitIceCandidate(candidate, toUid); },
190
- onCallEnded: (callId, toUid) => { emitCallEnd(callId, toUid); },
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
- callSession.state === 'calling' || callSession.state === 'connected';
274
+ const callInProgress = callSession.state === 'calling' || callSession.state === 'connected';
275
+ useEffect(() => { if (!callInProgress) setCallMinimized(false); }, [callInProgress]);
195
276
 
196
- useEffect(() => {
197
- if (!callInProgress) setCallMinimized(false);
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
- setActiveTab(p.activeTab);
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 pid = viewer?.projectId?.trim();
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, clearChat, viewer?.projectId, viewer?.uid, viewerUidForSocket, joinRoom]);
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
- /* ── Navigation ─────────────────────────────────────────────────────── */
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
- const roomId = [activeUser.uid, viewerUidForSocket].sort().join('_');
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
- /* ── Block/Unblock ──────────────────────────────────────────────────── */
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
- /* ── Tickets ────────────────────────────────────────────────────────── */
402
+ // ── Tickets ───────────────────────────────────────────────────────────────
387
403
  const handleRaiseTicket = useCallback((title: string, desc: string, priority: Ticket['priority']) => {
388
404
  const t: Ticket = {
389
- id: `TKT-${String(Date.now()).slice(-4)}`,
390
- title, description: desc, status: 'open', priority,
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
- setScreen('ticket-detail');
398
- setActiveTab('tickets');
399
- }, []);
409
+ setViewingTicketId(t.id); setScreen('ticket-detail'); setActiveTab('tickets');
410
+ emitTicketCreate({ title, description: desc, priority, createdBy: viewerUidForSocket });
411
+ }, [emitTicketCreate, viewerUidForSocket]);
400
412
 
401
- /* ── Pause sync back into recent chats ─────────────────────────────── */
413
+ // ── Pause sync ────────────────────────────────────────────────────────────
402
414
  const handleTogglePause = useCallback(() => {
403
415
  _togglePause();
404
- if (activeUser) {
405
- setRecentChats(prev => prev.map(r =>
406
- r.user.uid === activeUser.uid ? { ...r, isPaused: !isPaused } : r
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
- /* ── Call ───────────────────────────────────────────────────────────── */
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
- _endCall();
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
- /* ── Derived config (must be declared before callbacks that use it) ── */
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: `tr_${Date.now()}_${Math.random().toString(36).slice(2)}`,
449
- senderId: 'me',
450
- receiverId: dev.uid,
451
- text: `— ${agent} transferred this conversation from ${activeUser.name} to ${dev.name} —`,
452
- timestamp: new Date().toISOString(),
453
- type: 'text',
454
- status: 'sent',
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
- const roomId = [activeUser.uid, viewerUidForSocket].sort().join('_');
464
- emitAddParticipant(roomId, uid);
452
+ emitAddParticipant([activeUser.uid, viewerUidForSocket].sort().join('_'), uid);
465
453
  }, [activeUser, viewerUidForSocket, emitAddParticipant]);
466
454
 
467
- /* ── Derived ────────────────────────────────────────────────────────── */
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
- const totalUnread = useMemo(
509
- () => recentChats.reduce((sum, c) => sum + Math.max(0, c.unread ?? 0), 0),
510
- [recentChats],
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
- {/* ── Global styles ── */}
527
- <style>{`
528
- @import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700;800&display=swap');
529
- .cw-root * { box-sizing: border-box; font-family: 'DM Sans', 'Segoe UI', sans-serif; }
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: 'fixed', bottom: 80, ...posStyle, zIndex: 9996,
577
+ position:'fixed', bottom:80, ...posStyle, zIndex:9996,
552
578
  background: socketStatus === 'connecting' ? '#f59e0b' : '#ef4444',
553
- color: '#fff', fontSize: 11, fontWeight: 700,
554
- padding: '3px 10px', borderRadius: 20,
555
- animation: 'cw-fadeUp 0.3s ease',
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
- {/* ── Minimized call bar ── */}
586
+ {/* Mini call bar */}
562
587
  {!isOpen && callMinimized && callInProgress && callSession.peer && (
563
- <MiniCallBar
564
- session={callSession}
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
- {/* ── Floating Button ── */}
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
- title={totalUnread > 0 ? `${totalUnread} unread message${totalUnread === 1 ? '' : 's'}` : theme.buttonLabel}
580
- style={{
581
- position: 'fixed', bottom: 24, zIndex: 9999, ...posStyle,
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
- <span style={{ position: 'relative', display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>
601
- <svg width="20" height="20" viewBox="0 0 24 24" fill="none">
602
- <path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"
603
- stroke={theme.buttonTextColor} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
604
- </svg>
605
- {totalUnread > 0 && (
606
- <span style={{
607
- position: 'absolute', top: -8, right: -10,
608
- minWidth: 20, height: 20, padding: '0 5px', borderRadius: 999,
609
- background: '#ef4444', color: '#fff', fontSize: 11, fontWeight: 800,
610
- lineHeight: '20px', textAlign: 'center', border: '2px solid #fff', boxSizing: 'border-box',
611
- }}>
612
- {totalUnread > 99 ? '99+' : totalUnread}
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
- {/* ── Backdrop ── */}
621
- {isOpen && (
622
- <div
623
- aria-hidden
624
- style={{
625
- position: 'fixed', inset: 0, zIndex: 9997,
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
- {/* ── Drawer ── */}
624
+ {/* Panel */}
634
625
  {isOpen && (
635
626
  <div
636
- className={`cw-root cw-drawer-panel ${closing ? 'cw-drawer-exit' : 'cw-drawer-enter'}`}
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 configuration</p>
659
- <p style={{ fontSize:13,color:'#7b8fa1',lineHeight:1.6 }}>{cfgError}</p>
660
- <button onClick={closeDrawer} style={{ padding:'9px 20px',borderRadius:10,border:'none',background:primaryColor,color:'#fff',cursor:'pointer',fontWeight:700 }}>Close</button>
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
- {/* Close button */}
668
- {screen !== 'chat' && screen !== 'call' && !effectiveViewerBlocked && (
669
- <div style={{
670
- position:'absolute',top:12,
671
- right: theme.buttonPosition === 'bottom-left' ? 'auto' : 12,
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
- const CornerBtn: React.FC<{ onClick: () => void; title: string; children: React.ReactNode }> = ({ onClick, title, children }) => (
839
- <button onClick={onClick} title={title} style={{
840
- width:26,height:26,borderRadius:'50%',background:'rgba(0,0,0,0.25)',border:'none',
841
- display:'flex',alignItems:'center',justifyContent:'center',cursor:'pointer',
842
- }}>
843
- {children}
844
- </button>
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
+ }