ajaxter-chat 3.0.4 → 3.0.6

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.
@@ -1,7 +1,8 @@
1
1
  import React, { useState, useRef, useEffect, useCallback } from 'react';
2
- import { ChatMessage, ChatUser, WidgetConfig } from '../../types';
2
+ import { ChatMessage, ChatUser, WidgetConfig, UserListContext } from '../../types';
3
3
  import { avatarColor, initials, formatTime, formatDate, generateTranscript, downloadText } from '../../utils/chat';
4
4
  import { EmojiPicker } from '../EmojiPicker';
5
+ import { SlideNavMenu } from '../SlideNavMenu';
5
6
 
6
7
  interface ChatScreenProps {
7
8
  activeUser: ChatUser;
@@ -17,15 +18,23 @@ interface ChatScreenProps {
17
18
  onReport: () => void;
18
19
  onBlock: () => void;
19
20
  onStartCall: (withVideo: boolean) => void;
21
+ /** Navigate to support list, colleague list, or tickets (from slide menu) */
22
+ onNavAction: (ctx: UserListContext | 'ticket') => void;
23
+ /** Other devs (excl. viewer) — for transfer when staff chats with a customer */
24
+ otherDevelopers?: ChatUser[];
25
+ onTransferToDeveloper?: (dev: ChatUser) => void;
20
26
  }
21
27
 
22
28
  export const ChatScreen: React.FC<ChatScreenProps> = ({
23
29
  activeUser, messages, config, isPaused, isReported, isBlocked,
24
- onSend, onBack, onClose, onTogglePause, onReport, onBlock, onStartCall,
30
+ onSend, onBack, onClose, onTogglePause, onReport, onBlock, onStartCall, onNavAction,
31
+ otherDevelopers = [], onTransferToDeveloper,
25
32
  }) => {
26
33
  const [text, setText] = useState('');
27
34
  const [showEmoji, setShowEmoji] = useState(false);
28
35
  const [showMenu, setShowMenu] = useState(false);
36
+ const [slideMenuOpen, setSlideMenuOpen] = useState(false);
37
+ const [transferOpen, setTransferOpen] = useState(false);
29
38
  const [isRecording, setIsRecording] = useState(false);
30
39
  const [recordSec, setRecordSec] = useState(0);
31
40
  const [showConfirm, setShowConfirm] = useState<'report'|'block'|'pause'|null>(null);
@@ -35,6 +44,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({
35
44
  const fileRef = useRef<HTMLInputElement>(null);
36
45
  const recordTimer = useRef<ReturnType<typeof setInterval> | null>(null);
37
46
  const mediaRecorder = useRef<MediaRecorder | null>(null);
47
+ const recordChunks = useRef<BlobPart[]>([]);
38
48
 
39
49
  useEffect(() => { endRef.current?.scrollIntoView({ behavior:'smooth' }); }, [messages]);
40
50
 
@@ -49,21 +59,27 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({
49
59
  if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); }
50
60
  };
51
61
 
52
- // Voice recording
53
62
  const startRecording = async () => {
54
63
  if (isPaused || isBlocked) return;
55
64
  const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
65
+ recordChunks.current = [];
56
66
  const mr = new MediaRecorder(stream);
57
67
  mediaRecorder.current = mr;
58
- const chunks: BlobPart[] = [];
59
- mr.ondataavailable = e => chunks.push(e.data);
68
+ mr.ondataavailable = e => { if (e.data.size) recordChunks.current.push(e.data); };
60
69
  mr.onstop = () => {
61
70
  stream.getTracks().forEach(t => t.stop());
62
- // In production: upload blob, get URL, then send as voice message
63
- onSend('[Voice Message]', 'voice', { voiceDuration: recordSec });
71
+ const chunks = recordChunks.current;
72
+ if (!chunks.length) {
73
+ setRecordSec(0);
74
+ return;
75
+ }
76
+ const blob = new Blob(chunks, { type: chunks[0] instanceof Blob ? (chunks[0] as Blob).type : 'audio/webm' });
77
+ const voiceUrl = URL.createObjectURL(blob);
78
+ const dur = Math.max(1, recordSec);
79
+ onSend('Voice message', 'voice', { voiceDuration: dur, voiceUrl });
64
80
  setRecordSec(0);
65
81
  };
66
- mr.start();
82
+ mr.start(200);
67
83
  setIsRecording(true);
68
84
  recordTimer.current = setInterval(() => setRecordSec(s => s + 1), 1000);
69
85
  };
@@ -74,25 +90,24 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({
74
90
  setIsRecording(false);
75
91
  };
76
92
 
77
- // Attachment
78
93
  const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
79
94
  const file = e.target.files?.[0];
80
95
  if (!file || isPaused || isBlocked) return;
81
- onSend(`[Attachment: ${file.name}]`, 'attachment', {
96
+ const attachmentUrl = URL.createObjectURL(file);
97
+ onSend(file.name, 'attachment', {
82
98
  attachmentName: file.name,
83
99
  attachmentSize: `${(file.size / 1024).toFixed(1)} KB`,
100
+ attachmentUrl,
84
101
  });
85
102
  e.target.value = '';
86
103
  };
87
104
 
88
- // Download transcript
89
105
  const handleTranscript = () => {
90
106
  const content = generateTranscript(messages, activeUser);
91
107
  downloadText(content, `chat-${activeUser.name.replace(/\s+/g,'_')}-${Date.now()}.txt`);
92
108
  setShowMenu(false);
93
109
  };
94
110
 
95
- // Confirm actions
96
111
  const handleConfirm = (action: 'report'|'block'|'pause') => {
97
112
  setShowConfirm(null); setShowMenu(false);
98
113
  if (action === 'report') onReport();
@@ -102,20 +117,33 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({
102
117
 
103
118
  const peerAvatar = avatarColor(activeUser.name);
104
119
  const peerInit = initials(activeUser.name);
105
-
106
- // Group messages by date
107
120
  const grouped = groupByDate(messages);
108
121
 
122
+ const viewerIsDev = config.viewerType === 'developer';
123
+ const headerRole =
124
+ viewerIsDev
125
+ ? (activeUser.type === 'user' ? 'Customer' : 'Developer')
126
+ : 'Support';
127
+
109
128
  return (
110
- <div style={{ display:'flex', flexDirection:'column', height:'100%', animation:'cw-slideIn 0.22s ease', position:'relative' }}>
129
+ <div style={{ display:'flex', flexDirection:'column', height:'100%', animation:'cw-slideIn 0.22s ease', position:'relative', overflow: 'hidden' }}>
130
+
131
+ <SlideNavMenu
132
+ open={slideMenuOpen}
133
+ onClose={() => setSlideMenuOpen(false)}
134
+ primaryColor={config.primaryColor}
135
+ chatType={config.chatType}
136
+ viewerType={config.viewerType ?? 'user'}
137
+ onSelect={onNavAction}
138
+ onBackHome={onBack}
139
+ />
111
140
 
112
141
  {/* ── Header ── */}
113
142
  <div style={{
114
143
  background:`linear-gradient(135deg,${config.primaryColor},${config.primaryColor}cc)`,
115
- padding:'12px 14px', display:'flex', alignItems:'center', gap:10, flexShrink:0,
144
+ padding:'10px 12px', display:'flex', alignItems:'center', gap:8, flexShrink:0,
116
145
  }}>
117
- {/* hamburger/back */}
118
- <button onClick={onBack} style={hdrBtn}>
146
+ <button type="button" onClick={() => setSlideMenuOpen(true)} style={hdrBtn} aria-label="Open menu">
119
147
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none">
120
148
  <line x1="3" y1="6" x2="21" y2="6" stroke="#fff" strokeWidth="2.2" strokeLinecap="round"/>
121
149
  <line x1="3" y1="12" x2="21" y2="12" stroke="#fff" strokeWidth="2.2" strokeLinecap="round"/>
@@ -133,31 +161,53 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({
133
161
  <div style={{ fontSize:11, color:'rgba(255,255,255,0.8)' }}>{activeUser.designation}</div>
134
162
  </div>
135
163
 
136
- {/* Title "Support" */}
137
- <span style={{ fontSize:14, fontWeight:700, color:'#fff', opacity:0.9 }}>Support</span>
164
+ <span style={{ fontSize:13, fontWeight:700, color:'#fff', opacity:0.95, flexShrink:0 }}>{headerRole}</span>
138
165
 
139
- {/* Call button */}
140
166
  {config.allowWebCall && (
141
- <button onClick={() => onStartCall(false)} style={hdrBtn} title="Voice Call">
167
+ <button type="button" onClick={() => onStartCall(false)} style={hdrBtn} title="Voice Call">
142
168
  <svg width="17" height="17" viewBox="0 0 24 24" fill="none">
143
169
  <path d="M22 16.92v3a2 2 0 01-2.18 2 19.79 19.79 0 01-8.63-3.07A19.5 19.5 0 013.07 10.8a19.79 19.79 0 01-3.07-8.68A2 2 0 012 0h3a2 2 0 012 1.72c.127.96.361 1.903.7 2.81a2 2 0 01-.45 2.11L6.09 7.91a16 16 0 006 6l1.27-1.27a2 2 0 012.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0122 14.92v2z" fill="#fff"/>
144
170
  </svg>
145
171
  </button>
146
172
  )}
147
173
 
148
- {/* Maximize/expand icon */}
149
- <button style={hdrBtn} title="Fullscreen">
150
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none">
151
- <path d="M8 3H5a2 2 0 00-2 2v3M21 8V5a2 2 0 00-2-2h-3M3 16v3a2 2 0 002 2h3M16 21h3a2 2 0 002-2v-3" stroke="#fff" strokeWidth="2" strokeLinecap="round"/>
174
+ <button type="button" onClick={() => setShowMenu(v => !v)} style={{ ...hdrBtn, background:'rgba(255,255,255,0.2)' }} title="More options" aria-expanded={showMenu}>
175
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none">
176
+ <circle cx="12" cy="5" r="1.5" fill="#fff"/>
177
+ <circle cx="12" cy="12" r="1.5" fill="#fff"/>
178
+ <circle cx="12" cy="19" r="1.5" fill="#fff"/>
152
179
  </svg>
153
180
  </button>
154
181
  </div>
155
182
 
156
- {/* ── Paused / Blocked / Reported banners ── */}
183
+ {showMenu && (
184
+ <div style={{ position:'absolute', top:52, right:12, zIndex:120, background:'#fff', borderRadius:12, boxShadow:'0 8px 30px rgba(0,0,0,0.16)', padding:'6px', minWidth:200, animation:'cw-fadeUp 0.18s ease' }}>
185
+ {config.allowTranscriptDownload && (
186
+ <MenuItem icon="📥" label="Download Transcript" onClick={handleTranscript} />
187
+ )}
188
+ {viewerIsDev && activeUser.type === 'user' && otherDevelopers.length > 0 && onTransferToDeveloper && (
189
+ <MenuItem
190
+ icon="🔀"
191
+ label="Transfer to developer"
192
+ onClick={() => { setShowMenu(false); setTransferOpen(true); }}
193
+ />
194
+ )}
195
+ <MenuItem icon={isPaused ? '▶️' : '⏸'} label={isPaused ? 'Resume Chat' : 'Pause Chat'} onClick={() => { setShowMenu(false); setShowConfirm('pause'); }} />
196
+ {config.allowReport && !isReported && (
197
+ <MenuItem icon="⚠️" label="Report Chat" onClick={() => { setShowMenu(false); setShowConfirm('report'); }} />
198
+ )}
199
+ {config.allowBlock && activeUser.type === 'user' && !isBlocked && (
200
+ <MenuItem icon="🚫" label="Block User" onClick={() => { setShowMenu(false); setShowConfirm('block'); }} danger />
201
+ )}
202
+ <div style={{ borderTop:'1px solid #f0f2f5', margin:'4px 0' }} />
203
+ <MenuItem icon="✕" label="Close Chat" onClick={onClose} />
204
+ </div>
205
+ )}
206
+
157
207
  {isPaused && (
158
208
  <div style={{ background:'#fef3c7', padding:'8px 16px', fontSize:12, fontWeight:600, color:'#92400e', textAlign:'center', flexShrink:0, display:'flex', alignItems:'center', justifyContent:'center', gap:6 }}>
159
209
  ⏸ Chat is paused — users cannot send messages
160
- <button onClick={onTogglePause} style={{ background:'#92400e', color:'#fff', border:'none', borderRadius:6, padding:'2px 8px', fontSize:11, cursor:'pointer', marginLeft:4 }}>Resume</button>
210
+ <button type="button" onClick={onTogglePause} style={{ background:'#92400e', color:'#fff', border:'none', borderRadius:6, padding:'2px 8px', fontSize:11, cursor:'pointer', marginLeft:4 }}>Resume</button>
161
211
  </div>
162
212
  )}
163
213
  {isBlocked && (
@@ -171,24 +221,6 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({
171
221
  </div>
172
222
  )}
173
223
 
174
- {/* ── "Enter your details" card (ticket chat body placeholder) ── */}
175
- <div style={{ padding:'14px 14px 0', flexShrink:0 }}>
176
- <div style={{
177
- background:`linear-gradient(135deg, ${config.primaryColor}, ${config.primaryColor}cc)`,
178
- borderRadius:12, padding:'14px 16px',
179
- display:'flex', alignItems:'center', gap:12, cursor:'pointer',
180
- }}>
181
- <div style={{ width:32, height:32, borderRadius:'50%', background:'rgba(255,255,255,0.25)', display:'flex', alignItems:'center', justifyContent:'center', flexShrink:0 }}>
182
- <span style={{ fontSize:16 }}>❓</span>
183
- </div>
184
- <div>
185
- <div style={{ fontWeight:700, fontSize:14, color:'#fff' }}>Enter your details</div>
186
- <div style={{ fontSize:12, color:'rgba(255,255,255,0.85)' }}>Click here to provide your information</div>
187
- </div>
188
- </div>
189
- </div>
190
-
191
- {/* ── Messages ── */}
192
224
  <div style={{ flex:1, overflowY:'auto', padding:'14px', display:'flex', flexDirection:'column', gap:10, background:'#f8f9fc' }}
193
225
  className="cw-scroll"
194
226
  >
@@ -196,7 +228,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({
196
228
  <React.Fragment key={date}>
197
229
  <DateDivider label={date} />
198
230
  {msgs.map(msg => (
199
- <Bubble key={msg.id} msg={msg} peer={activeUser} primaryColor={config.primaryColor} />
231
+ <Bubble key={msg.id} msg={msg} primaryColor={config.primaryColor} />
200
232
  ))}
201
233
  </React.Fragment>
202
234
  ))}
@@ -209,10 +241,9 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({
209
241
  <div ref={endRef} />
210
242
  </div>
211
243
 
212
- {/* ── Input bar ── */}
213
- <div style={{ borderTop:'1px solid #eef0f5', padding:'10px 14px', background:'#fff', flexShrink:0, position:'relative' }}>
244
+ {/* Composer reference layout */}
245
+ <div style={{ borderTop:'1px solid #eef0f5', padding:'10px 12px 8px', background:'#fff', flexShrink:0, position:'relative' }}>
214
246
 
215
- {/* Emoji picker */}
216
247
  {showEmoji && config.allowEmoji && (
217
248
  <EmojiPicker
218
249
  primaryColor={config.primaryColor}
@@ -221,124 +252,192 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({
221
252
  />
222
253
  )}
223
254
 
224
- {/* Recording indicator */}
225
255
  {isRecording && (
226
- <div style={{ display:'flex', alignItems:'center', gap:8, marginBottom:8, padding:'6px 12px', background:'#fee2e2', borderRadius:8 }}>
256
+ <div style={{ display:'flex', alignItems:'center', gap:8, marginBottom:8, padding:'8px 12px', background:'#fee2e2', borderRadius:10 }}>
227
257
  <span style={{ width:8, height:8, borderRadius:'50%', background:'#ef4444', display:'inline-block', animation:'cw-pulse 1s infinite' }} />
228
258
  <span style={{ fontSize:13, color:'#991b1b', fontWeight:600 }}>Recording {recordSec}s</span>
229
- <button onClick={stopRecording} style={{ marginLeft:'auto', background:'#ef4444', color:'#fff', border:'none', borderRadius:6, padding:'3px 10px', fontSize:12, cursor:'pointer' }}>Stop</button>
259
+ <button type="button" onClick={stopRecording} style={{ marginLeft:'auto', background:'#ef4444', color:'#fff', border:'none', borderRadius:6, padding:'4px 12px', fontSize:12, cursor:'pointer', fontWeight:600 }}>Stop & send</button>
230
260
  </div>
231
261
  )}
232
262
 
233
- <div style={{ display:'flex', alignItems:'flex-end', gap:8 }}>
263
+ <div style={{
264
+ border: `1.5px solid ${isPaused || isBlocked ? '#e5e7eb' : '#bfdbfe'}`,
265
+ borderRadius: 16,
266
+ padding: '10px 12px 8px',
267
+ background: isPaused || isBlocked ? '#f9fafb' : '#fff',
268
+ }}>
234
269
  <textarea
235
270
  ref={inputRef}
236
271
  value={text}
237
272
  onChange={e => setText(e.target.value)}
238
273
  onKeyDown={handleKey}
239
- placeholder={isPaused || isBlocked ? 'Chat is unavailable' : 'Message...'}
274
+ placeholder={isPaused || isBlocked ? 'Chat is unavailable' : 'Compose your message…'}
240
275
  disabled={isPaused || isBlocked || isRecording}
241
- rows={1}
276
+ rows={2}
242
277
  style={{
243
- flex:1, resize:'none', border:'1.5px solid #e5e7eb',
244
- borderRadius:22, padding:'9px 14px',
245
- fontSize:14, outline:'none', lineHeight:1.5,
246
- maxHeight:80, overflowY:'auto', color:'#1a2332',
247
- background: isPaused || isBlocked ? '#f9fafb' : '#fff',
248
- transition:'border-color 0.2s',
249
- fontFamily:'inherit',
278
+ width: '100%',
279
+ resize: 'none',
280
+ border: 'none',
281
+ outline: 'none',
282
+ fontSize: 14,
283
+ lineHeight: 1.45,
284
+ color: '#1a2332',
285
+ background: 'transparent',
286
+ maxHeight: 88,
287
+ overflowY: 'auto',
288
+ fontFamily: 'inherit',
289
+ marginBottom: 8,
250
290
  }}
251
- onFocus={e => (e.target.style.borderColor = config.primaryColor)}
252
- onBlur={e => (e.target.style.borderColor = '#e5e7eb')}
253
291
  />
254
-
255
- {/* Emoji */}
256
- {config.allowEmoji && (
257
- <ActionBtn onClick={() => setShowEmoji(v => !v)} title="Emoji">
258
- <svg width="19" height="19" viewBox="0 0 24 24" fill="none">
259
- <circle cx="12" cy="12" r="10" stroke="#9ca3af" strokeWidth="1.8"/>
260
- <path d="M8 14s1.5 2 4 2 4-2 4-2" stroke="#9ca3af" strokeWidth="1.8" strokeLinecap="round"/>
261
- <circle cx="9" cy="9" r="1" fill="#9ca3af"/><circle cx="15" cy="9" r="1" fill="#9ca3af"/>
262
- </svg>
263
- </ActionBtn>
264
- )}
265
-
266
- {/* Attachment */}
267
- {config.allowAttachment && (
268
- <>
269
- <input ref={fileRef} type="file" style={{ display:'none' }} onChange={handleFileChange} />
270
- <ActionBtn onClick={() => fileRef.current?.click()} title="Attach file">
271
- <svg width="19" height="19" viewBox="0 0 24 24" fill="none">
272
- <path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66L9.41 17.41a2 2 0 01-2.83-2.83l8.49-8.48" stroke="#9ca3af" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/>
273
- </svg>
274
- </ActionBtn>
275
- </>
276
- )}
277
-
278
- {/* Voice */}
279
- {config.allowVoiceMessage && !isRecording && (
280
- <ActionBtn onClick={startRecording} title="Voice message">
281
- <svg width="19" height="19" viewBox="0 0 24 24" fill="none">
282
- <path d="M12 1a3 3 0 00-3 3v8a3 3 0 006 0V4a3 3 0 00-3-3z" stroke="#9ca3af" strokeWidth="1.8" strokeLinecap="round"/>
283
- <path d="M19 10v2a7 7 0 01-14 0v-2M12 19v4M8 23h8" stroke="#9ca3af" strokeWidth="1.8" strokeLinecap="round"/>
284
- </svg>
285
- </ActionBtn>
286
- )}
287
-
288
- {/* Send */}
289
- {text.trim() && (
290
- <button onClick={handleSend} style={{
291
- width:36, height:36, borderRadius:'50%', backgroundColor:config.primaryColor,
292
- border:'none', cursor:'pointer', display:'flex', alignItems:'center', justifyContent:'center', flexShrink:0,
293
- transition:'transform 0.15s',
294
- }}>
292
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
293
+ <div style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
294
+ {config.allowEmoji && (
295
+ <ActionBtn onClick={() => setShowEmoji(v => !v)} title="Emoji">
296
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none">
297
+ <circle cx="12" cy="12" r="10" stroke="#94a3b8" strokeWidth="1.8"/>
298
+ <path d="M8 14s1.5 2 4 2 4-2 4-2" stroke="#94a3b8" strokeWidth="1.8" strokeLinecap="round"/>
299
+ <circle cx="9" cy="9" r="1" fill="#94a3b8"/><circle cx="15" cy="9" r="1" fill="#94a3b8"/>
300
+ </svg>
301
+ </ActionBtn>
302
+ )}
303
+ {config.allowAttachment && (
304
+ <>
305
+ <input ref={fileRef} type="file" style={{ display:'none' }} onChange={handleFileChange} />
306
+ <ActionBtn onClick={() => fileRef.current?.click()} title="Attach file">
307
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none">
308
+ <path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66L9.41 17.41a2 2 0 01-2.83-2.83l8.49-8.48" stroke="#94a3b8" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/>
309
+ </svg>
310
+ </ActionBtn>
311
+ </>
312
+ )}
313
+ {config.allowVoiceMessage && !isRecording && (
314
+ <ActionBtn onClick={startRecording} title="Voice message">
315
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none">
316
+ <path d="M12 1a3 3 0 00-3 3v8a3 3 0 006 0V4a3 3 0 00-3-3z" stroke="#94a3b8" strokeWidth="1.8" strokeLinecap="round"/>
317
+ <path d="M19 10v2a7 7 0 01-14 0v-2M12 19v4M8 23h8" stroke="#94a3b8" strokeWidth="1.8" strokeLinecap="round"/>
318
+ </svg>
319
+ </ActionBtn>
320
+ )}
321
+ </div>
322
+ <button
323
+ type="button"
324
+ onClick={handleSend}
325
+ disabled={!text.trim() || isPaused || isBlocked || isRecording}
326
+ style={{
327
+ width: 36,
328
+ height: 36,
329
+ borderRadius: '50%',
330
+ backgroundColor: text.trim() && !isPaused && !isBlocked ? config.primaryColor : '#e2e8f0',
331
+ border: 'none',
332
+ cursor: text.trim() && !isPaused && !isBlocked ? 'pointer' : 'default',
333
+ display: 'flex',
334
+ alignItems: 'center',
335
+ justifyContent: 'center',
336
+ flexShrink: 0,
337
+ transition: 'background 0.15s',
338
+ }}
339
+ title="Send"
340
+ >
295
341
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none">
296
- <path d="M22 2L11 13M22 2L15 22L11 13L2 9L22 2Z" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
342
+ <path d="M22 2L11 13M22 2L15 22L11 13L2 9L22 2Z" stroke={text.trim() && !isPaused && !isBlocked ? '#fff' : '#94a3b8'} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
297
343
  </svg>
298
344
  </button>
299
- )}
345
+ </div>
300
346
  </div>
347
+
348
+ {(config.footerPoweredBy || config.branch) && (
349
+ <p style={{ margin: '10px 0 0', textAlign: 'center', fontSize: 12, color: '#94a3b8' }}>
350
+ {config.footerPoweredBy}
351
+ {config.footerPoweredBy && config.branch ? ' · ' : ''}
352
+ {config.branch && <span style={{ fontWeight: 600, color: '#64748b' }}>{config.branch}</span>}
353
+ </p>
354
+ )}
301
355
  </div>
302
356
 
303
- {/* ── 3-dot overflow menu ── */}
304
- <div style={{ position:'absolute', top:12, right:52, zIndex:50 }}>
305
- <button
306
- onClick={() => setShowMenu(v => !v)}
307
- style={{ ...hdrBtn, background:'rgba(255,255,255,0.18)' }}
308
- title="More options"
357
+ {transferOpen && otherDevelopers.length > 0 && onTransferToDeveloper && (
358
+ <div
359
+ style={{
360
+ position: 'absolute',
361
+ inset: 0,
362
+ background: 'rgba(0,0,0,0.45)',
363
+ display: 'flex',
364
+ alignItems: 'center',
365
+ justifyContent: 'center',
366
+ zIndex: 280,
367
+ padding: 16,
368
+ }}
309
369
  >
310
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none">
311
- <circle cx="12" cy="5" r="1.5" fill="#fff"/>
312
- <circle cx="12" cy="12" r="1.5" fill="#fff"/>
313
- <circle cx="12" cy="19" r="1.5" fill="#fff"/>
314
- </svg>
315
- </button>
316
-
317
- {showMenu && (
318
- <div style={{
319
- position:'absolute', top:'100%', right:0, marginTop:4,
320
- background:'#fff', borderRadius:12,
321
- boxShadow:'0 8px 30px rgba(0,0,0,0.16)',
322
- padding:'6px', minWidth:200, zIndex:200,
323
- animation:'cw-fadeUp 0.18s ease',
324
- }}>
325
- {config.allowTranscriptDownload && (
326
- <MenuItem icon="📥" label="Download Transcript" onClick={handleTranscript} />
327
- )}
328
- <MenuItem icon={isPaused ? '▶️' : '⏸'} label={isPaused ? 'Resume Chat' : 'Pause Chat'} onClick={() => { setShowMenu(false); setShowConfirm('pause'); }} />
329
- {config.allowReport && !isReported && (
330
- <MenuItem icon="⚠️" label="Report Chat" onClick={() => { setShowMenu(false); setShowConfirm('report'); }} />
331
- )}
332
- {config.allowBlock && activeUser.type === 'user' && !isBlocked && (
333
- <MenuItem icon="🚫" label="Block User" onClick={() => { setShowMenu(false); setShowConfirm('block'); }} danger />
334
- )}
335
- <div style={{ borderTop:'1px solid #f0f2f5', margin:'4px 0' }} />
336
- <MenuItem icon="✕" label="Close Chat" onClick={onClose} />
370
+ <div
371
+ style={{
372
+ background: '#fff',
373
+ borderRadius: 16,
374
+ padding: '18px 16px',
375
+ width: '100%',
376
+ maxWidth: 320,
377
+ maxHeight: '70%',
378
+ overflow: 'hidden',
379
+ display: 'flex',
380
+ flexDirection: 'column',
381
+ boxShadow: '0 16px 48px rgba(0,0,0,0.22)',
382
+ }}
383
+ >
384
+ <div style={{ fontWeight: 800, fontSize: 16, color: '#1a2332', marginBottom: 6 }}>
385
+ Transfer chat to
386
+ </div>
387
+ <p style={{ fontSize: 12, color: '#7b8fa1', margin: '0 0 12px', lineHeight: 1.5 }}>
388
+ Assign this conversation to another developer. History is kept and a handoff note is added.
389
+ </p>
390
+ <div className="cw-scroll" style={{ flex: 1, overflowY: 'auto', margin: '0 -4px' }}>
391
+ {otherDevelopers.map(dev => (
392
+ <button
393
+ key={dev.uid}
394
+ type="button"
395
+ onClick={() => {
396
+ onTransferToDeveloper(dev);
397
+ setTransferOpen(false);
398
+ }}
399
+ style={{
400
+ width: '100%',
401
+ textAlign: 'left',
402
+ padding: '12px 12px',
403
+ marginBottom: 6,
404
+ border: '1px solid #eef0f5',
405
+ borderRadius: 12,
406
+ background: '#f8fafc',
407
+ cursor: 'pointer',
408
+ fontSize: 14,
409
+ fontWeight: 600,
410
+ color: '#1e293b',
411
+ }}
412
+ >
413
+ {dev.name}
414
+ <span style={{ display: 'block', fontSize: 11, fontWeight: 500, color: '#64748b', marginTop: 2 }}>
415
+ {dev.designation}
416
+ </span>
417
+ </button>
418
+ ))}
419
+ </div>
420
+ <button
421
+ type="button"
422
+ onClick={() => setTransferOpen(false)}
423
+ style={{
424
+ marginTop: 12,
425
+ padding: '10px',
426
+ borderRadius: 10,
427
+ border: '1.5px solid #e5e7eb',
428
+ background: '#fff',
429
+ fontWeight: 600,
430
+ fontSize: 13,
431
+ color: '#475569',
432
+ cursor: 'pointer',
433
+ }}
434
+ >
435
+ Cancel
436
+ </button>
337
437
  </div>
338
- )}
339
- </div>
438
+ </div>
439
+ )}
340
440
 
341
- {/* ── Confirm dialog ── */}
342
441
  {showConfirm && (
343
442
  <div style={{
344
443
  position:'absolute', inset:0, background:'rgba(0,0,0,0.45)',
@@ -357,10 +456,10 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({
357
456
  {showConfirm === 'block' && 'This user will be blocked and added to your block list. You can unblock them later.'}
358
457
  </p>
359
458
  <div style={{ display:'flex', gap:10 }}>
360
- <button onClick={() => setShowConfirm(null)} style={{ flex:1, padding:'9px', borderRadius:10, border:'1.5px solid #e5e7eb', background:'#fff', cursor:'pointer', fontSize:13, fontWeight:600, color:'#374151' }}>
459
+ <button type="button" onClick={() => setShowConfirm(null)} style={{ flex:1, padding:'9px', borderRadius:10, border:'1.5px solid #e5e7eb', background:'#fff', cursor:'pointer', fontSize:13, fontWeight:600, color:'#374151' }}>
361
460
  Cancel
362
461
  </button>
363
- <button onClick={() => handleConfirm(showConfirm)} style={{
462
+ <button type="button" onClick={() => handleConfirm(showConfirm)} style={{
364
463
  flex:1, padding:'9px', borderRadius:10, border:'none',
365
464
  background: showConfirm==='block' ? '#ef4444' : config.primaryColor,
366
465
  color:'#fff', cursor:'pointer', fontSize:13, fontWeight:700,
@@ -375,35 +474,138 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({
375
474
  );
376
475
  };
377
476
 
378
- // ── Sub-components ─────────────────────────────────────────────────────────────
477
+ const VoiceRow: React.FC<{ msg: ChatMessage; isMe: boolean; primaryColor: string }> = ({ msg, isMe, primaryColor }) => {
478
+ const audioRef = useRef<HTMLAudioElement>(null);
479
+ const [playing, setPlaying] = useState(false);
480
+ const [current, setCurrent] = useState(0);
481
+ const [dur, setDur] = useState(msg.voiceDuration ?? 0);
482
+
483
+ const url = msg.voiceUrl;
484
+ useEffect(() => {
485
+ const a = audioRef.current;
486
+ if (!a || !url) return;
487
+ const onMeta = () => setDur(a.duration || msg.voiceDuration || 0);
488
+ const onTime = () => setCurrent(a.currentTime);
489
+ a.addEventListener('loadedmetadata', onMeta);
490
+ a.addEventListener('timeupdate', onTime);
491
+ return () => {
492
+ a.removeEventListener('loadedmetadata', onMeta);
493
+ a.removeEventListener('timeupdate', onTime);
494
+ };
495
+ }, [url, msg.voiceDuration]);
379
496
 
380
- const Bubble: React.FC<{ msg: ChatMessage; peer: ChatUser; primaryColor: string }> = ({ msg, peer, primaryColor }) => {
381
- const isMe = msg.senderId === 'me';
497
+ const toggle = () => {
498
+ const a = audioRef.current;
499
+ if (!a) return;
500
+ if (playing) { a.pause(); setPlaying(false); }
501
+ else { void a.play().then(() => setPlaying(true)).catch(() => {}); }
502
+ };
382
503
 
383
- const content = msg.type === 'voice' ? (
384
- <div style={{ display:'flex', alignItems:'center', gap:8 }}>
385
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none">
386
- <path d="M12 1a3 3 0 00-3 3v8a3 3 0 006 0V4a3 3 0 00-3-3z" stroke={isMe?'#fff':'#374151'} strokeWidth="2" strokeLinecap="round"/>
387
- <path d="M19 10v2a7 7 0 01-14 0v-2M12 19v4" stroke={isMe?'#fff':'#374151'} strokeWidth="2" strokeLinecap="round"/>
388
- </svg>
389
- <span>Voice Message {msg.voiceDuration ? `· ${msg.voiceDuration}s` : ''}</span>
504
+ const pct = dur > 0 ? Math.min(100, (current / dur) * 100) : 0;
505
+ const timeLabel = fmtTime(Math.floor(current)) + ' / ' + fmtTime(Math.floor(dur || msg.voiceDuration || 0));
506
+
507
+ if (!url) {
508
+ return (
509
+ <div style={{ display:'flex', alignItems:'center', gap:8 }}>
510
+ <span style={{ fontSize:13 }}>🎤</span>
511
+ <span style={{ fontSize:13 }}>Voice message{msg.voiceDuration ? ` · ${msg.voiceDuration}s` : ''}</span>
512
+ </div>
513
+ );
514
+ }
515
+
516
+ return (
517
+ <div style={{ display:'flex', alignItems:'center', gap:10, minWidth: 200 }}>
518
+ {url && (
519
+ <audio
520
+ ref={audioRef}
521
+ src={url}
522
+ preload="metadata"
523
+ onPlay={() => setPlaying(true)}
524
+ onPause={() => setPlaying(false)}
525
+ onEnded={() => { setPlaying(false); setCurrent(0); }}
526
+ />
527
+ )}
528
+ <button
529
+ type="button"
530
+ onClick={toggle}
531
+ style={{
532
+ width: 36,
533
+ height: 36,
534
+ borderRadius: '50%',
535
+ border: 'none',
536
+ background: isMe ? 'rgba(255,255,255,0.95)' : '#fff',
537
+ color: isMe ? primaryColor : primaryColor,
538
+ cursor: 'pointer',
539
+ display: 'flex',
540
+ alignItems: 'center',
541
+ justifyContent: 'center',
542
+ flexShrink: 0,
543
+ boxShadow: '0 1px 4px rgba(0,0,0,0.12)',
544
+ }}
545
+ aria-label={playing ? 'Pause' : 'Play'}
546
+ >
547
+ {playing ? (
548
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>
549
+ ) : (
550
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
551
+ )}
552
+ </button>
553
+ <div style={{ flex: 1, minWidth: 0 }}>
554
+ <div style={{ height: 4, borderRadius: 2, background: isMe ? 'rgba(255,255,255,0.35)' : '#e2e8f0', overflow: 'hidden' }}>
555
+ <div style={{ width: `${pct}%`, height: '100%', background: isMe ? '#fff' : primaryColor, borderRadius: 2, transition: 'width 0.1s linear' }} />
556
+ </div>
557
+ <div style={{ fontSize: 11, marginTop: 4, opacity: 0.9 }}>{timeLabel}</div>
558
+ </div>
390
559
  </div>
391
- ) : msg.type === 'attachment' ? (
392
- <div style={{ display:'flex', alignItems:'center', gap:8 }}>
393
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none">
394
- <path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66L9.41 17.41a2 2 0 01-2.83-2.83l8.49-8.48" stroke={isMe?'#fff':'#374151'} strokeWidth="2" strokeLinecap="round"/>
560
+ );
561
+ };
562
+
563
+ const AttachmentRow: React.FC<{ msg: ChatMessage; isMe: boolean; primaryColor: string }> = ({ msg, isMe, primaryColor }) => {
564
+ const name = msg.attachmentName ?? 'File';
565
+ const href = msg.attachmentUrl;
566
+ return (
567
+ <div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
568
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" style={{ flexShrink: 0 }}>
569
+ <path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66L9.41 17.41a2 2 0 01-2.83-2.83l8.49-8.48" stroke={isMe ? '#fff' : '#334155'} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
395
570
  </svg>
396
- <div>
397
- <div style={{ fontWeight:600 }}>{msg.attachmentName ?? 'File'}</div>
398
- {msg.attachmentSize && <div style={{ fontSize:11, opacity:0.75 }}>{msg.attachmentSize}</div>}
571
+ <div style={{ flex: 1, minWidth: 0 }}>
572
+ <div style={{ fontWeight: 700, fontSize: 14, wordBreak: 'break-word' }}>{name}</div>
573
+ {msg.attachmentSize && <div style={{ fontSize: 11, opacity: 0.8 }}>{msg.attachmentSize}</div>}
399
574
  </div>
575
+ {href && (
576
+ <a
577
+ href={href}
578
+ download={name}
579
+ style={{
580
+ fontSize: 12,
581
+ fontWeight: 700,
582
+ color: isMe ? '#fff' : primaryColor,
583
+ textDecoration: 'underline',
584
+ whiteSpace: 'nowrap',
585
+ }}
586
+ >
587
+ Download
588
+ </a>
589
+ )}
400
590
  </div>
401
- ) : <span>{msg.text}</span>;
591
+ );
592
+ };
593
+
594
+ const Bubble: React.FC<{ msg: ChatMessage; primaryColor: string }> = ({ msg, primaryColor }) => {
595
+ const isMe = msg.senderId === 'me';
596
+
597
+ const content = msg.type === 'voice' ? (
598
+ <VoiceRow msg={msg} isMe={isMe} primaryColor={primaryColor} />
599
+ ) : msg.type === 'attachment' ? (
600
+ <AttachmentRow msg={msg} isMe={isMe} primaryColor={primaryColor} />
601
+ ) : (
602
+ <span>{msg.text}</span>
603
+ );
402
604
 
403
605
  return (
404
606
  <div style={{ display:'flex', flexDirection:'column', alignItems: isMe?'flex-end':'flex-start', gap:3 }}>
405
607
  <div style={{
406
- maxWidth:'76%', padding:'10px 13px',
608
+ maxWidth:'85%', padding:'10px 13px',
407
609
  borderRadius: isMe ? '18px 18px 4px 18px' : '18px 18px 18px 4px',
408
610
  backgroundColor: isMe ? primaryColor : '#fff',
409
611
  color: isMe ? '#fff' : '#1a2332',
@@ -421,13 +623,13 @@ const Bubble: React.FC<{ msg: ChatMessage; peer: ChatUser; primaryColor: string
421
623
  const DateDivider: React.FC<{ label: string }> = ({ label }) => (
422
624
  <div style={{ display:'flex', alignItems:'center', gap:10, margin:'4px 0' }}>
423
625
  <div style={{ flex:1, height:1, background:'#e5e7eb' }} />
424
- <span style={{ fontSize:11, fontWeight:600, color:'#9ca3af', whiteSpace:'nowrap' }}>{label}</span>
626
+ <span style={{ fontSize:11, fontWeight:600, color:'#64748b', whiteSpace:'nowrap' }}>{label}</span>
425
627
  <div style={{ flex:1, height:1, background:'#e5e7eb' }} />
426
628
  </div>
427
629
  );
428
630
 
429
631
  const MenuItem: React.FC<{ icon: string; label: string; onClick: () => void; danger?: boolean }> = ({ icon, label, onClick, danger }) => (
430
- <button onClick={onClick} style={{
632
+ <button type="button" onClick={onClick} style={{
431
633
  display:'flex', alignItems:'center', gap:10, width:'100%', padding:'9px 12px',
432
634
  background:'none', border:'none', borderRadius:8, cursor:'pointer', textAlign:'left',
433
635
  fontSize:13, fontWeight:600, color: danger ? '#ef4444' : '#374151',
@@ -441,12 +643,12 @@ const MenuItem: React.FC<{ icon: string; label: string; onClick: () => void; dan
441
643
  );
442
644
 
443
645
  const ActionBtn: React.FC<{ onClick: () => void; title: string; children: React.ReactNode }> = ({ onClick, title, children }) => (
444
- <button onClick={onClick} title={title} style={{
445
- background:'none', border:'none', cursor:'pointer', padding:'7px',
646
+ <button type="button" onClick={onClick} title={title} style={{
647
+ background:'none', border:'none', cursor:'pointer', padding:'8px',
446
648
  borderRadius:'50%', display:'flex', alignItems:'center', justifyContent:'center', flexShrink:0,
447
649
  transition:'background 0.13s',
448
650
  }}
449
- onMouseEnter={e => (e.currentTarget as HTMLElement).style.background = '#f3f4f6'}
651
+ onMouseEnter={e => (e.currentTarget as HTMLElement).style.background = '#f1f5f9'}
450
652
  onMouseLeave={e => (e.currentTarget as HTMLElement).style.background = 'none'}
451
653
  >{children}</button>
452
654
  );
@@ -457,7 +659,6 @@ const hdrBtn: React.CSSProperties = {
457
659
  cursor:'pointer', flexShrink:0,
458
660
  };
459
661
 
460
- // Group messages by date
461
662
  function groupByDate(messages: ChatMessage[]): { date: string; msgs: ChatMessage[] }[] {
462
663
  const map = new Map<string, ChatMessage[]>();
463
664
  messages.forEach(m => {
@@ -467,3 +668,9 @@ function groupByDate(messages: ChatMessage[]): { date: string; msgs: ChatMessage
467
668
  });
468
669
  return Array.from(map.entries()).map(([date, msgs]) => ({ date, msgs }));
469
670
  }
671
+
672
+ function fmtTime(sec: number): string {
673
+ const m = Math.floor(sec / 60);
674
+ const s = Math.max(0, sec % 60);
675
+ return `${m}:${String(s).padStart(2, '0')}`;
676
+ }