@consilioweb/payload-support 0.8.2 → 0.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/components/RichTextEditor/index.cjs +233 -0
  2. package/dist/components/RichTextEditor/index.js +232 -0
  3. package/dist/components/TicketConversation/components/CodeBlock.cjs +24 -7
  4. package/dist/components/TicketConversation/components/CodeBlock.js +23 -9
  5. package/dist/index.cjs +19 -1
  6. package/dist/index.js +19 -1
  7. package/dist/views/BillingView/client.cjs +260 -103
  8. package/dist/views/BillingView/client.js +259 -103
  9. package/dist/views/ChatView/client.cjs +184 -137
  10. package/dist/views/ChatView/client.js +180 -137
  11. package/dist/views/CrmView/client.cjs +270 -122
  12. package/dist/views/CrmView/client.js +266 -122
  13. package/dist/views/EmailTrackingView/client.cjs +80 -69
  14. package/dist/views/EmailTrackingView/client.js +80 -70
  15. package/dist/views/ImportConversationView/client.cjs +127 -94
  16. package/dist/views/ImportConversationView/client.js +123 -94
  17. package/dist/views/LogsView/client.cjs +56 -58
  18. package/dist/views/LogsView/client.js +52 -58
  19. package/dist/views/NewTicketView/client.cjs +39 -55
  20. package/dist/views/NewTicketView/client.js +38 -55
  21. package/dist/views/PendingEmailsView/client.cjs +399 -102
  22. package/dist/views/PendingEmailsView/client.js +396 -103
  23. package/dist/views/SupportDashboardView/client.cjs +276 -137
  24. package/dist/views/SupportDashboardView/client.js +275 -137
  25. package/dist/views/TicketDetailView/client.cjs +487 -204
  26. package/dist/views/TicketDetailView/client.js +486 -204
  27. package/dist/views/TicketInboxView/client.cjs +62 -65
  28. package/dist/views/TicketInboxView/client.js +62 -66
  29. package/dist/views/TicketingSettingsView/client.cjs +10 -8
  30. package/dist/views/TicketingSettingsView/client.js +10 -8
  31. package/dist/views/TimeDashboardView/client.cjs +70 -59
  32. package/dist/views/TimeDashboardView/client.js +69 -59
  33. package/package.json +6 -2
  34. package/src/components/RichTextEditor/index.tsx +261 -0
  35. package/src/components/TicketConversation/components/CodeBlock.tsx +53 -14
  36. package/src/plugin.ts +2 -0
  37. package/src/utils/emailTemplate.ts +37 -0
  38. package/src/views/BillingView/client.tsx +362 -69
  39. package/src/views/ChatView/client.tsx +225 -140
  40. package/src/views/CrmView/client.tsx +447 -189
  41. package/src/views/EmailTrackingView/client.tsx +111 -71
  42. package/src/views/ImportConversationView/client.tsx +255 -70
  43. package/src/views/LogsView/client.tsx +85 -50
  44. package/src/views/NewTicketView/client.tsx +37 -53
  45. package/src/views/PendingEmailsView/client.tsx +512 -92
  46. package/src/views/SupportDashboardView/client.tsx +294 -134
  47. package/src/views/TicketDetailView/client.tsx +486 -213
  48. package/src/views/TicketInboxView/client.tsx +52 -61
  49. package/src/views/TicketingSettingsView/client.tsx +10 -9
  50. package/src/views/TimeDashboardView/client.tsx +184 -69
@@ -2,7 +2,7 @@
2
2
 
3
3
  import React, { useState, useEffect, useRef, useCallback } from 'react'
4
4
  import { useTranslation } from '../../components/TicketConversation/hooks/useTranslation'
5
- import s from '../../styles/ChatView.module.scss'
5
+ import styles from '../../styles/ChatView.module.scss'
6
6
 
7
7
  interface ChatSession {
8
8
  session: string
@@ -32,65 +32,58 @@ export const ChatViewClient: React.FC = () => {
32
32
  const [sending, setSending] = useState(false)
33
33
  const [showClosed, setShowClosed] = useState(false)
34
34
  const [loading, setLoading] = useState(true)
35
- const [cannedResponses, setCannedResponses] = useState<{ id: string | number; title: string; body: string }[]>([])
35
+ const [cannedResponses, setCannedResponses] = useState<{ id: string | number; title: string; body: string; category?: string }[]>([])
36
36
  const messagesEndRef = useRef<HTMLDivElement>(null)
37
37
  const lastFetchRef = useRef<string | null>(null)
38
- const [sessionExpired, setSessionExpired] = useState(false)
39
- const sessionsESRef = useRef<EventSource | null>(null)
40
- const messagesESRef = useRef<EventSource | null>(null)
38
+ const sessionsPollInterval = useRef(5000)
39
+ const sessionsPollTimeout = useRef<NodeJS.Timeout>(undefined)
40
+ const messagesPollInterval = useRef(3000)
41
+ const messagesPollTimeout = useRef<NodeJS.Timeout>(undefined)
41
42
 
43
+ // Fetch sessions list
44
+ const [sessionExpired, setSessionExpired] = useState(false)
42
45
  const fetchSessions = useCallback(async () => {
46
+ let hadChanges = false
43
47
  try {
44
48
  const res = await fetch('/api/support/admin-chat')
45
49
  if (res.status === 401 || res.status === 403) { setSessionExpired(true); return }
46
50
  if (res.ok) {
47
51
  const data = await res.json()
48
- setSessions({ active: data.active || [], closed: data.closed || [] })
52
+ setSessions((prev) => {
53
+ const newActive = data.active || []
54
+ const newClosed = data.closed || []
55
+ if (JSON.stringify(prev.active) !== JSON.stringify(newActive) || JSON.stringify(prev.closed) !== JSON.stringify(newClosed)) {
56
+ hadChanges = true
57
+ return { active: newActive, closed: newClosed }
58
+ }
59
+ return prev
60
+ })
49
61
  }
50
62
  } catch { /* ignore */ }
51
63
  setLoading(false)
64
+ if (hadChanges) {
65
+ sessionsPollInterval.current = 5000
66
+ } else {
67
+ sessionsPollInterval.current = Math.min(sessionsPollInterval.current + 2000, 15000)
68
+ }
52
69
  }, [])
53
70
 
54
- // SSE for session list with polling fallback
55
71
  useEffect(() => {
56
- if (sessionExpired) return
57
-
58
- // Always fetch once for initial data
59
72
  fetchSessions()
73
+ if (sessionExpired) return
60
74
 
61
- if (typeof EventSource !== 'undefined') {
62
- const es = new EventSource('/api/support/admin-chat-stream')
63
- sessionsESRef.current = es
64
-
65
- es.onmessage = (event) => {
66
- try {
67
- const parsed = JSON.parse(event.data)
68
- if (parsed.type === 'sessions' && parsed.data) {
69
- setSessions({ active: parsed.data.active || [], closed: parsed.data.closed || [] })
70
- setLoading(false)
71
- }
72
- } catch { /* ignore parse errors */ }
73
- }
74
-
75
- es.onerror = () => {
76
- // SSE failed, fall back to polling
77
- es.close()
78
- sessionsESRef.current = null
79
- const iv = setInterval(fetchSessions, 5000)
80
- return () => clearInterval(iv)
81
- }
82
-
83
- return () => {
84
- es.close()
85
- sessionsESRef.current = null
86
- }
75
+ const schedulePoll = () => {
76
+ sessionsPollTimeout.current = setTimeout(async () => {
77
+ await fetchSessions()
78
+ schedulePoll()
79
+ }, sessionsPollInterval.current)
87
80
  }
81
+ schedulePoll()
88
82
 
89
- // Fallback: polling
90
- const iv = setInterval(fetchSessions, 5000)
91
- return () => clearInterval(iv)
83
+ return () => clearTimeout(sessionsPollTimeout.current)
92
84
  }, [fetchSessions, sessionExpired])
93
85
 
86
+ // Fetch canned responses
94
87
  useEffect(() => {
95
88
  fetch('/api/canned-responses?sort=sortOrder&limit=50&depth=0', { credentials: 'include' })
96
89
  .then((res) => res.ok ? res.json() : null)
@@ -98,11 +91,12 @@ export const ChatViewClient: React.FC = () => {
98
91
  .catch(() => {})
99
92
  }, [])
100
93
 
101
- // SSE for messages in selected session with polling fallback
94
+ // Fetch messages for selected session (polling)
102
95
  useEffect(() => {
103
96
  if (!selectedSession) return
104
97
 
105
98
  const fetchMessages = async () => {
99
+ let hadNewMessages = false
106
100
  try {
107
101
  const after = lastFetchRef.current || ''
108
102
  const url = `/api/support/admin-chat?session=${selectedSession}${after ? `&after=${after}` : ''}`
@@ -110,73 +104,65 @@ export const ChatViewClient: React.FC = () => {
110
104
  if (res.ok) {
111
105
  const data = await res.json()
112
106
  if (!lastFetchRef.current) {
107
+ // Initial load
113
108
  setMessages(data.messages || [])
109
+ hadNewMessages = (data.messages?.length || 0) > 0
114
110
  } else if (data.messages?.length > 0) {
115
111
  setMessages((prev) => {
116
112
  const ids = new Set(prev.map((m) => m.id))
117
113
  const newMsgs = data.messages.filter((m: ChatMessage) => !ids.has(m.id))
118
114
  return newMsgs.length > 0 ? [...prev, ...newMsgs] : prev
119
115
  })
116
+ hadNewMessages = true
120
117
  }
121
118
  if (data.messages?.length > 0) {
122
119
  lastFetchRef.current = data.messages[data.messages.length - 1].createdAt
123
120
  }
124
121
  }
125
122
  } catch { /* ignore */ }
123
+ if (hadNewMessages) {
124
+ messagesPollInterval.current = 3000
125
+ } else {
126
+ messagesPollInterval.current = Math.min(messagesPollInterval.current + 1000, 10000)
127
+ }
126
128
  }
127
129
 
128
- // Always load initial messages via REST
129
130
  lastFetchRef.current = null
131
+ messagesPollInterval.current = 3000
130
132
  fetchMessages()
131
133
 
132
- // Then try SSE for real-time updates
133
- if (typeof EventSource !== 'undefined') {
134
- const es = new EventSource(`/api/support/admin-chat-stream?session=${selectedSession}`)
135
- messagesESRef.current = es
136
-
137
- es.onmessage = (event) => {
138
- try {
139
- const parsed = JSON.parse(event.data)
140
- if (parsed.type === 'messages' && parsed.data?.length > 0) {
141
- setMessages((prev) => {
142
- const ids = new Set(prev.map((m) => m.id))
143
- const newMsgs = parsed.data.filter((m: ChatMessage) => !ids.has(m.id))
144
- return newMsgs.length > 0 ? [...prev, ...newMsgs] : prev
145
- })
146
- }
147
- } catch { /* ignore parse errors */ }
148
- }
149
-
150
- es.onerror = () => {
151
- // SSE failed, fall back to polling
152
- es.close()
153
- messagesESRef.current = null
154
- const iv = setInterval(fetchMessages, 3000)
155
- // Store interval for cleanup — use a local ref
156
- ;(fetchMessages as any)._fallbackIv = iv
157
- }
158
-
159
- return () => {
160
- es.close()
161
- messagesESRef.current = null
162
- if ((fetchMessages as any)._fallbackIv) clearInterval((fetchMessages as any)._fallbackIv)
163
- }
134
+ const schedulePoll = () => {
135
+ messagesPollTimeout.current = setTimeout(async () => {
136
+ await fetchMessages()
137
+ schedulePoll()
138
+ }, messagesPollInterval.current)
164
139
  }
140
+ schedulePoll()
165
141
 
166
- // Fallback: polling
167
- const iv = setInterval(fetchMessages, 3000)
168
- return () => clearInterval(iv)
142
+ return () => clearTimeout(messagesPollTimeout.current)
169
143
  }, [selectedSession])
170
144
 
171
- useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [messages])
145
+ useEffect(() => {
146
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
147
+ }, [messages])
172
148
 
173
149
  const sendMessage = async (e: React.FormEvent) => {
174
150
  e.preventDefault()
175
151
  if (!input.trim() || !selectedSession || sending) return
152
+
176
153
  setSending(true)
177
154
  try {
178
- const res = await fetch('/api/support/admin-chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'send', session: selectedSession, message: input.trim() }) })
179
- if (res.ok) { const data = await res.json(); setMessages((prev) => [...prev, data.message]); lastFetchRef.current = data.message.createdAt; setInput('') }
155
+ const res = await fetch('/api/support/admin-chat', {
156
+ method: 'POST',
157
+ headers: { 'Content-Type': 'application/json' },
158
+ body: JSON.stringify({ action: 'send', session: selectedSession, message: input.trim() }),
159
+ })
160
+ if (res.ok) {
161
+ const data = await res.json()
162
+ setMessages((prev) => [...prev, data.message])
163
+ lastFetchRef.current = data.message.createdAt
164
+ setInput('')
165
+ }
180
166
  } catch { /* ignore */ }
181
167
  setSending(false)
182
168
  }
@@ -184,7 +170,11 @@ export const ChatViewClient: React.FC = () => {
184
170
  const closeSession = async () => {
185
171
  if (!selectedSession) return
186
172
  try {
187
- await fetch('/api/support/admin-chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'close', session: selectedSession }) })
173
+ await fetch('/api/support/admin-chat', {
174
+ method: 'POST',
175
+ headers: { 'Content-Type': 'application/json' },
176
+ body: JSON.stringify({ action: 'close', session: selectedSession }),
177
+ })
188
178
  setSelectedSession(null)
189
179
  fetchSessions()
190
180
  } catch { /* ignore */ }
@@ -197,92 +187,187 @@ export const ChatViewClient: React.FC = () => {
197
187
  return client.email || `Client #${client.id}`
198
188
  }
199
189
 
200
- const displayedSessions = showClosed ? sessions.closed : sessions.active
201
-
202
- const S: Record<string, React.CSSProperties> = {
203
- page: { padding: '20px 30px', maxWidth: 1200, margin: '0 auto' },
204
- container: { display: 'grid', gridTemplateColumns: '320px 1fr', gap: 16, minHeight: 'calc(100vh - 300px)' },
205
- sidebar: { borderRight: '1px solid var(--theme-elevation-200)' },
206
- sessionItem: { display: 'block', width: '100%', padding: '10px 14px', border: 'none', background: 'none', cursor: 'pointer', textAlign: 'left' as const, borderBottom: '1px solid var(--theme-elevation-100)', fontSize: 13 },
207
- sessionActive: { background: 'var(--theme-elevation-50)' },
208
- chatPanel: { display: 'flex', flexDirection: 'column' as const },
209
- messagesArea: { flex: 1, overflowY: 'auto' as const, padding: '12px 0' },
210
- bubble: { maxWidth: '70%', padding: '8px 12px', borderRadius: 10, marginBottom: 8, fontSize: 14 },
211
- bubbleAgent: { background: '#dbeafe', color: '#1e3a5f', marginLeft: 'auto' },
212
- bubbleClient: { background: 'var(--theme-elevation-100)', color: 'var(--theme-text)' },
213
- bubbleSystem: { margin: '4px auto', padding: '4px 12px', fontSize: 11, color: '#6b7280', textAlign: 'center' as const },
214
- composer: { borderTop: '1px solid var(--theme-elevation-200)', padding: '8px 0' },
215
- composerInput: { flex: 1, padding: '8px 12px', borderRadius: 8, border: '1px solid var(--theme-elevation-200)', fontSize: 13, background: 'var(--theme-elevation-0)', color: 'var(--theme-text)' },
216
- sendBtn: { padding: '8px 16px', borderRadius: 8, background: '#2563eb', color: '#fff', border: 'none', fontWeight: 600, cursor: 'pointer', fontSize: 13 },
217
- tabsRow: { display: 'flex', gap: 4, padding: '8px 14px', borderBottom: '1px solid var(--theme-elevation-200)' },
218
- tab: { padding: '4px 10px', borderRadius: 6, border: 'none', background: 'none', cursor: 'pointer', fontSize: 12, color: 'var(--theme-elevation-500)' },
219
- tabActive: { background: 'var(--theme-elevation-100)', fontWeight: 700, color: 'var(--theme-text)' },
190
+ const getClientCompany = (client: ChatSession['client']): string => {
191
+ if (typeof client === 'number') return ''
192
+ return client.company || ''
220
193
  }
221
194
 
195
+ const displayedSessions = showClosed ? sessions.closed : sessions.active
196
+
222
197
  return (
223
- <div style={S.page}>
224
- <div style={{ marginBottom: 16 }}>
225
- <h1 style={{ fontSize: 22, fontWeight: 700, margin: 0, color: 'var(--theme-text)' }}>{t('chat.title')}</h1>
226
- <p style={{ color: 'var(--theme-elevation-500)', fontSize: 13, margin: '4px 0 0' }}>{sessions.active.length !== 1 ? t('chat.sessionCountPlural', { count: String(sessions.active.length) }) : t('chat.sessionCount', { count: String(sessions.active.length) })}</p>
198
+ <div className={styles.page}>
199
+ <div className={styles.header}>
200
+ <div>
201
+ <h1 className={styles.title}>{t('chat.title')}</h1>
202
+ <p className={styles.subtitle}>
203
+ {sessions.active.length !== 1 ? t('chat.sessionCountPlural', { count: String(sessions.active.length) }) : t('chat.sessionCount', { count: String(sessions.active.length) })}
204
+ </p>
205
+ </div>
227
206
  </div>
228
207
 
229
- <div style={S.container}>
230
- <div style={S.sidebar}>
231
- <div style={S.tabsRow}>
232
- <button onClick={() => setShowClosed(false)} style={{ ...S.tab, ...(!showClosed ? S.tabActive : {}) }}>{t('chat.tabs.active')} ({sessions.active.length})</button>
233
- <button onClick={() => setShowClosed(true)} style={{ ...S.tab, ...(showClosed ? S.tabActive : {}) }}>{t('chat.tabs.closed')} ({sessions.closed.length})</button>
208
+ <div className={styles.container}>
209
+ {/* Sessions sidebar */}
210
+ <div className={styles.sidebar}>
211
+ {/* Tabs */}
212
+ <div className={styles.tabs}>
213
+ <button
214
+ onClick={() => setShowClosed(false)}
215
+ className={`${styles.tab} ${!showClosed ? styles.tabActive : ''}`}
216
+ >
217
+ {t('chat.tabs.active')} ({sessions.active.length})
218
+ </button>
219
+ <button
220
+ onClick={() => setShowClosed(true)}
221
+ className={`${styles.tab} ${showClosed ? styles.tabActive : ''}`}
222
+ >
223
+ {t('chat.tabs.closed')} ({sessions.closed.length})
224
+ </button>
234
225
  </div>
235
- <div>
236
- {loading ? <div style={{ padding: 20, textAlign: 'center', color: '#94a3b8' }}>{t('common.loading')}</div>
237
- : displayedSessions.length === 0 ? <div style={{ padding: 20, textAlign: 'center', color: '#94a3b8' }}>{showClosed ? t('chat.noSessionClosed') : t('chat.noSessionActive')}</div>
238
- : displayedSessions.map((s) => (
239
- <button key={s.session} onClick={() => setSelectedSession(s.session)} style={{ ...S.sessionItem, ...(selectedSession === s.session ? S.sessionActive : {}) }}>
240
- <div style={{ display: 'flex', justifyContent: 'space-between' }}>
241
- <span style={{ fontWeight: 600 }}>{getClientName(s.client)}</span>
242
- {s.unreadCount > 0 && <span style={{ padding: '1px 6px', borderRadius: 10, background: '#dc2626', color: '#fff', fontSize: 10, fontWeight: 700 }}>{s.unreadCount}</span>}
243
- </div>
244
- <div style={{ fontSize: 12, color: 'var(--theme-elevation-500)', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{s.lastMessage}</div>
245
- <div style={{ fontSize: 11, color: 'var(--theme-elevation-400)', marginTop: 2 }}>{new Date(s.lastMessageAt).toLocaleString('fr-FR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })} -- {s.messageCount} {t('chat.msg')}</div>
246
- </button>
247
- ))}
226
+
227
+ {/* Session list */}
228
+ <div className={styles.sessionList}>
229
+ {loading ? (
230
+ <div className={styles.loadingState}>
231
+ <div className={styles.emptyState}>{t('common.loading')}</div>
232
+ </div>
233
+ ) : displayedSessions.length === 0 ? (
234
+ <div className={styles.emptyState}>
235
+ {showClosed ? t('chat.noSessionClosed') : t('chat.noSessionActive')}
236
+ </div>
237
+ ) : displayedSessions.map((s) => (
238
+ <button
239
+ key={s.session}
240
+ onClick={() => setSelectedSession(s.session)}
241
+ className={`${styles.sessionItem} ${selectedSession === s.session ? styles.sessionItemActive : ''}`}
242
+ >
243
+ <div className={styles.sessionHeader}>
244
+ <span className={styles.sessionName}>{getClientName(s.client)}</span>
245
+ {s.unreadCount > 0 && (
246
+ <span className={styles.unreadBadge}>{s.unreadCount}</span>
247
+ )}
248
+ </div>
249
+ {getClientCompany(s.client) && (
250
+ <div className={styles.sessionCompany}>{getClientCompany(s.client)}</div>
251
+ )}
252
+ <div className={styles.sessionPreview}>
253
+ {s.lastMessage.startsWith('Note:') ? (
254
+ <span className={styles.sessionRating}>{s.lastMessage.match(/[★☆]+/)?.[0] || '⭐'}</span>
255
+ ) : s.lastMessage}
256
+ </div>
257
+ <div className={styles.sessionMeta}>
258
+ {new Date(s.lastMessageAt).toLocaleString('fr-FR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })}
259
+ {' · '}{s.messageCount} {t('chat.msg')}
260
+ </div>
261
+ </button>
262
+ ))}
248
263
  </div>
249
264
  </div>
250
265
 
251
- <div style={S.chatPanel}>
266
+ {/* Chat area */}
267
+ <div className={styles.chatPanel}>
252
268
  {!selectedSession ? (
253
- <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#94a3b8', fontSize: 14 }}>{t('chat.selectSession')}</div>
269
+ <div className={styles.chatEmpty}>
270
+ {t('chat.selectSession')}
271
+ </div>
254
272
  ) : (
255
273
  <>
256
- <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '8px 14px', borderBottom: '1px solid var(--theme-elevation-200)' }}>
257
- <span style={{ fontFamily: 'monospace', fontSize: 12, color: 'var(--theme-elevation-500)' }}>{selectedSession}</span>
258
- <button onClick={closeSession} style={{ padding: '4px 12px', borderRadius: 6, border: '1px solid #dc2626', background: 'none', color: '#dc2626', fontSize: 12, cursor: 'pointer' }}>{t('chat.closeChat')}</button>
274
+ {/* Chat header */}
275
+ <div className={styles.chatHeader}>
276
+ <span className={styles.chatSessionId}>{selectedSession}</span>
277
+ <button onClick={closeSession} className={styles.closeBtn}>
278
+ {t('chat.closeChat')}
279
+ </button>
259
280
  </div>
260
- <div style={S.messagesArea}>
281
+
282
+ {/* Messages */}
283
+ <div className={styles.messagesArea}>
261
284
  {messages.map((msg) => (
262
- <div key={msg.id} style={{ display: 'flex', flexDirection: msg.senderType === 'agent' ? 'row-reverse' : 'row', padding: '2px 14px' }}>
285
+ <div
286
+ key={msg.id}
287
+ className={`${styles.messageRow} ${
288
+ msg.senderType === 'agent'
289
+ ? styles.messageRowAgent
290
+ : msg.senderType === 'system'
291
+ ? styles.messageRowSystem
292
+ : styles.messageRowClient
293
+ }`}
294
+ >
263
295
  {msg.senderType === 'system' ? (
264
- <div style={S.bubbleSystem}>{msg.message}</div>
296
+ msg.message.startsWith('Note:') || msg.message.startsWith('Commentaire:') ? (
297
+ <div className={styles.bubbleRating}>
298
+ {msg.message.includes('★') && (
299
+ <div className={styles.ratingStars}>
300
+ {msg.message.match(/[★☆]+/)?.[0] || ''}
301
+ </div>
302
+ )}
303
+ <div className={styles.ratingComment}>
304
+ {msg.message.includes('—')
305
+ ? msg.message.split('—').slice(1).join('—').trim()
306
+ : msg.message.replace(/Note:\s*[★☆]+\s*\(\d\/5\)\s*/, '').replace('Commentaire: ', '')}
307
+ </div>
308
+ <div className={styles.ratingMeta}>
309
+ {t('chat.clientReview')} · {new Date(msg.createdAt).toLocaleString('fr-FR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })}
310
+ </div>
311
+ </div>
312
+ ) : (
313
+ <div className={styles.bubbleSystem}>
314
+ {msg.message}
315
+ </div>
316
+ )
265
317
  ) : (
266
- <div style={{ ...S.bubble, ...(msg.senderType === 'agent' ? S.bubbleAgent : S.bubbleClient) }}>
267
- <div style={{ fontSize: 11, fontWeight: 600, marginBottom: 2 }}>{msg.senderType === 'agent' ? t('chat.you') : t('chat.clientLabel')}</div>
268
- <div>{msg.message}</div>
269
- <div style={{ fontSize: 10, color: msg.senderType === 'agent' ? '#1e40af' : 'var(--theme-elevation-400)', marginTop: 2 }}>{new Date(msg.createdAt).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}</div>
318
+ <div className={`${styles.bubble} ${msg.senderType === 'agent' ? styles.bubbleAgent : styles.bubbleClient}`}>
319
+ <div className={styles.bubbleSender}>
320
+ {msg.senderType === 'agent'
321
+ ? (msg.agent ? `${(msg.agent as { firstName?: string }).firstName || t('chat.agent')}` : t('chat.you'))
322
+ : t('chat.clientLabel')}
323
+ </div>
324
+ <div className={styles.bubbleBody}>
325
+ {msg.message}
326
+ </div>
327
+ <div className={styles.bubbleTime}>
328
+ {new Date(msg.createdAt).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}
329
+ </div>
270
330
  </div>
271
331
  )}
272
332
  </div>
273
333
  ))}
274
334
  <div ref={messagesEndRef} />
275
335
  </div>
276
- <form onSubmit={sendMessage} style={S.composer}>
336
+
337
+ {/* Reply input */}
338
+ <form onSubmit={sendMessage} className={styles.composer}>
277
339
  {cannedResponses.length > 0 && (
278
- <select onChange={(e) => { const cr = cannedResponses.find((c) => String(c.id) === e.target.value); if (cr) setInput(cr.body); e.target.value = '' }} style={{ padding: '4px 8px', borderRadius: 6, border: '1px solid var(--theme-elevation-200)', fontSize: 11, marginBottom: 6, color: 'var(--theme-text)', background: 'var(--theme-elevation-0)' }}>
340
+ <select
341
+ onChange={(e) => {
342
+ const cr = cannedResponses.find((c) => String(c.id) === e.target.value)
343
+ if (cr) setInput(cr.body)
344
+ e.target.value = ''
345
+ }}
346
+ className={styles.cannedSelect}
347
+ >
279
348
  <option value="">{t('chat.quickReply')}</option>
280
- {cannedResponses.map((cr) => <option key={cr.id} value={String(cr.id)}>{cr.title}</option>)}
349
+ {cannedResponses.map((cr) => (
350
+ <option key={cr.id} value={String(cr.id)}>{cr.title}</option>
351
+ ))}
281
352
  </select>
282
353
  )}
283
- <div style={{ display: 'flex', gap: 8 }}>
284
- <input type="text" value={input} onChange={(e) => setInput(e.target.value)} placeholder={t('chat.inputPlaceholder')} maxLength={2000} style={S.composerInput} autoFocus />
285
- <button type="submit" disabled={!input.trim() || sending} style={S.sendBtn}>{t('chat.sendButton')}</button>
354
+ <div className={styles.composerRow}>
355
+ <input
356
+ type="text"
357
+ value={input}
358
+ onChange={(e) => setInput(e.target.value)}
359
+ placeholder={t('chat.inputPlaceholder')}
360
+ maxLength={2000}
361
+ className={styles.composerInput}
362
+ autoFocus
363
+ />
364
+ <button
365
+ type="submit"
366
+ disabled={!input.trim() || sending}
367
+ className={styles.sendBtn}
368
+ >
369
+ {t('chat.sendButton')}
370
+ </button>
286
371
  </div>
287
372
  </form>
288
373
  </>