@consilioweb/payload-support 0.5.1

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 (189) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +525 -0
  3. package/dist/client.cjs +7 -0
  4. package/dist/client.d.cts +3 -0
  5. package/dist/client.d.ts +3 -0
  6. package/dist/client.js +5 -0
  7. package/dist/index.cjs +7766 -0
  8. package/dist/index.d.cts +384 -0
  9. package/dist/index.d.ts +384 -0
  10. package/dist/index.js +7730 -0
  11. package/dist/views.d.cts +30 -0
  12. package/dist/views.d.ts +30 -0
  13. package/package.json +131 -0
  14. package/src/client.ts +1 -0
  15. package/src/collections/AuthLogs.ts +65 -0
  16. package/src/collections/CannedResponses.ts +69 -0
  17. package/src/collections/ChatMessages.ts +98 -0
  18. package/src/collections/EmailLogs.ts +94 -0
  19. package/src/collections/KnowledgeBase.ts +99 -0
  20. package/src/collections/Macros.ts +98 -0
  21. package/src/collections/PendingEmails.ts +122 -0
  22. package/src/collections/SatisfactionSurveys.ts +98 -0
  23. package/src/collections/SlaPolicies.ts +123 -0
  24. package/src/collections/SupportClients.ts +210 -0
  25. package/src/collections/TicketActivityLog.ts +81 -0
  26. package/src/collections/TicketMessages.ts +364 -0
  27. package/src/collections/TicketStatuses.ts +108 -0
  28. package/src/collections/Tickets.ts +704 -0
  29. package/src/collections/TimeEntries.ts +105 -0
  30. package/src/collections/WebhookEndpoints.ts +96 -0
  31. package/src/collections/index.ts +16 -0
  32. package/src/components/TicketConversation/components/AISummaryPanel.tsx +85 -0
  33. package/src/components/TicketConversation/components/ActionPanels.tsx +140 -0
  34. package/src/components/TicketConversation/components/ActivityLog.tsx +39 -0
  35. package/src/components/TicketConversation/components/ClientBar.tsx +37 -0
  36. package/src/components/TicketConversation/components/ClientHistory.tsx +117 -0
  37. package/src/components/TicketConversation/components/CodeBlock.tsx +186 -0
  38. package/src/components/TicketConversation/components/CodeBlockInserter.tsx +166 -0
  39. package/src/components/TicketConversation/components/QuickActions.tsx +82 -0
  40. package/src/components/TicketConversation/components/TicketHeader.tsx +91 -0
  41. package/src/components/TicketConversation/components/TimeTrackingPanel.tsx +161 -0
  42. package/src/components/TicketConversation/config.ts +82 -0
  43. package/src/components/TicketConversation/constants.ts +74 -0
  44. package/src/components/TicketConversation/context.ts +63 -0
  45. package/src/components/TicketConversation/hooks/useAI.ts +180 -0
  46. package/src/components/TicketConversation/hooks/useMessageActions.ts +131 -0
  47. package/src/components/TicketConversation/hooks/useReply.ts +190 -0
  48. package/src/components/TicketConversation/hooks/useTicketActions.ts +205 -0
  49. package/src/components/TicketConversation/hooks/useTimeTracking.ts +107 -0
  50. package/src/components/TicketConversation/hooks/useTranslation.ts +116 -0
  51. package/src/components/TicketConversation/index.tsx +1110 -0
  52. package/src/components/TicketConversation/locales/en.json +878 -0
  53. package/src/components/TicketConversation/locales/fr.json +878 -0
  54. package/src/components/TicketConversation/types.ts +54 -0
  55. package/src/components/TicketConversation/utils.ts +25 -0
  56. package/src/endpoints/admin-chat-stream.ts +238 -0
  57. package/src/endpoints/admin-chat.ts +263 -0
  58. package/src/endpoints/admin-stats.ts +200 -0
  59. package/src/endpoints/ai.ts +199 -0
  60. package/src/endpoints/apply-macro.ts +144 -0
  61. package/src/endpoints/auth-2fa.ts +163 -0
  62. package/src/endpoints/auto-close.ts +175 -0
  63. package/src/endpoints/billing.ts +167 -0
  64. package/src/endpoints/bulk-action.ts +103 -0
  65. package/src/endpoints/chat-stream.ts +127 -0
  66. package/src/endpoints/chat.ts +188 -0
  67. package/src/endpoints/chatbot.ts +113 -0
  68. package/src/endpoints/delete-account.ts +129 -0
  69. package/src/endpoints/email-stats.ts +109 -0
  70. package/src/endpoints/export-csv.ts +84 -0
  71. package/src/endpoints/export-data.ts +104 -0
  72. package/src/endpoints/import-conversation.ts +307 -0
  73. package/src/endpoints/index.ts +154 -0
  74. package/src/endpoints/login.ts +92 -0
  75. package/src/endpoints/merge-clients.ts +132 -0
  76. package/src/endpoints/merge-tickets.ts +137 -0
  77. package/src/endpoints/oauth-google.ts +179 -0
  78. package/src/endpoints/pending-emails-process.ts +224 -0
  79. package/src/endpoints/presence.ts +104 -0
  80. package/src/endpoints/process-scheduled.ts +144 -0
  81. package/src/endpoints/purge-logs.ts +58 -0
  82. package/src/endpoints/resend-notification.ts +99 -0
  83. package/src/endpoints/round-robin-config.ts +92 -0
  84. package/src/endpoints/satisfaction.ts +93 -0
  85. package/src/endpoints/search.ts +106 -0
  86. package/src/endpoints/seed-kb.ts +153 -0
  87. package/src/endpoints/settings.ts +144 -0
  88. package/src/endpoints/signature.ts +93 -0
  89. package/src/endpoints/sla-check.ts +124 -0
  90. package/src/endpoints/split-ticket.ts +131 -0
  91. package/src/endpoints/statuses.ts +45 -0
  92. package/src/endpoints/track-open.ts +154 -0
  93. package/src/endpoints/typing.ts +101 -0
  94. package/src/endpoints/user-prefs.ts +125 -0
  95. package/src/hooks/checkSLA.ts +414 -0
  96. package/src/hooks/ticketStatusEmail.ts +182 -0
  97. package/src/index.ts +51 -0
  98. package/src/plugin.ts +157 -0
  99. package/src/portal/LiveChat.tsx +1353 -0
  100. package/src/portal/auth/ChatWidget.tsx +350 -0
  101. package/src/portal/auth/ChatbotWidget.tsx +285 -0
  102. package/src/portal/auth/SupportHeader.tsx +409 -0
  103. package/src/portal/auth/dashboard/DashboardClient.tsx +650 -0
  104. package/src/portal/auth/dashboard/page.tsx +84 -0
  105. package/src/portal/auth/faq/FAQSearch.tsx +117 -0
  106. package/src/portal/auth/faq/page.tsx +199 -0
  107. package/src/portal/auth/layout.tsx +61 -0
  108. package/src/portal/auth/profile/page.tsx +705 -0
  109. package/src/portal/auth/tickets/detail/CloseTicketButton.tsx +74 -0
  110. package/src/portal/auth/tickets/detail/CollapsibleMessages.tsx +46 -0
  111. package/src/portal/auth/tickets/detail/MarkSolutionButton.tsx +50 -0
  112. package/src/portal/auth/tickets/detail/MessageActions.tsx +158 -0
  113. package/src/portal/auth/tickets/detail/PrintButton.tsx +16 -0
  114. package/src/portal/auth/tickets/detail/ReadReceipt.tsx +34 -0
  115. package/src/portal/auth/tickets/detail/ReopenTicketButton.tsx +74 -0
  116. package/src/portal/auth/tickets/detail/SatisfactionForm.tsx +156 -0
  117. package/src/portal/auth/tickets/detail/TicketPolling.tsx +57 -0
  118. package/src/portal/auth/tickets/detail/TicketReplyForm.tsx +294 -0
  119. package/src/portal/auth/tickets/detail/TypingIndicator.tsx +58 -0
  120. package/src/portal/auth/tickets/detail/page.tsx +738 -0
  121. package/src/portal/auth/tickets/new/page.tsx +515 -0
  122. package/src/portal/forgot-password/page.tsx +114 -0
  123. package/src/portal/layout.tsx +26 -0
  124. package/src/portal/locales/en.json +374 -0
  125. package/src/portal/locales/fr.json +374 -0
  126. package/src/portal/login/page.tsx +351 -0
  127. package/src/portal/page.tsx +162 -0
  128. package/src/portal/register/page.tsx +281 -0
  129. package/src/portal/reset-password/page.tsx +152 -0
  130. package/src/styles/BillingView.module.scss +311 -0
  131. package/src/styles/ChatView.module.scss +438 -0
  132. package/src/styles/CommandPalette.module.scss +160 -0
  133. package/src/styles/CrmView.module.scss +554 -0
  134. package/src/styles/EmailTracking.module.scss +238 -0
  135. package/src/styles/ImportConversation.module.scss +267 -0
  136. package/src/styles/Layout.module.scss +55 -0
  137. package/src/styles/Logs.module.scss +164 -0
  138. package/src/styles/NewTicket.module.scss +143 -0
  139. package/src/styles/PendingEmails.module.scss +629 -0
  140. package/src/styles/SupportDashboard.module.scss +649 -0
  141. package/src/styles/TicketDetail.module.scss +1043 -0
  142. package/src/styles/TicketInbox.module.scss +296 -0
  143. package/src/styles/TicketingSettings.module.scss +358 -0
  144. package/src/styles/TimeDashboard.module.scss +287 -0
  145. package/src/styles/_tokens.scss +78 -0
  146. package/src/styles/theme.css +633 -0
  147. package/src/types.ts +255 -0
  148. package/src/utils/adminNotification.ts +38 -0
  149. package/src/utils/auth.ts +46 -0
  150. package/src/utils/emailTemplate.ts +343 -0
  151. package/src/utils/fireWebhooks.ts +84 -0
  152. package/src/utils/index.ts +22 -0
  153. package/src/utils/rateLimiter.ts +52 -0
  154. package/src/utils/readSettings.ts +67 -0
  155. package/src/utils/slugs.ts +54 -0
  156. package/src/utils/webhookDispatcher.ts +120 -0
  157. package/src/views/BillingView/client.tsx +137 -0
  158. package/src/views/BillingView/index.tsx +33 -0
  159. package/src/views/ChatView/client.tsx +294 -0
  160. package/src/views/ChatView/index.tsx +33 -0
  161. package/src/views/CrmView/client.tsx +206 -0
  162. package/src/views/CrmView/index.tsx +33 -0
  163. package/src/views/EmailTrackingView/client.tsx +124 -0
  164. package/src/views/EmailTrackingView/index.tsx +33 -0
  165. package/src/views/ImportConversationView/client.tsx +133 -0
  166. package/src/views/ImportConversationView/index.tsx +33 -0
  167. package/src/views/LogsView/client.tsx +151 -0
  168. package/src/views/LogsView/index.tsx +30 -0
  169. package/src/views/NewTicketView/client.tsx +227 -0
  170. package/src/views/NewTicketView/index.tsx +30 -0
  171. package/src/views/PendingEmailsView/client.tsx +177 -0
  172. package/src/views/PendingEmailsView/index.tsx +33 -0
  173. package/src/views/SupportDashboardView/client.tsx +424 -0
  174. package/src/views/SupportDashboardView/index.tsx +33 -0
  175. package/src/views/TicketDetailView/client.tsx +775 -0
  176. package/src/views/TicketDetailView/index.tsx +33 -0
  177. package/src/views/TicketInboxView/client.tsx +313 -0
  178. package/src/views/TicketInboxView/index.tsx +30 -0
  179. package/src/views/TicketingSettingsView/client.tsx +866 -0
  180. package/src/views/TicketingSettingsView/index.tsx +33 -0
  181. package/src/views/TimeDashboardView/client.tsx +144 -0
  182. package/src/views/TimeDashboardView/index.tsx +33 -0
  183. package/src/views/shared/AdminViewHeader.tsx +69 -0
  184. package/src/views/shared/ErrorBoundary.tsx +68 -0
  185. package/src/views/shared/Skeleton.tsx +125 -0
  186. package/src/views/shared/adminTokens.ts +37 -0
  187. package/src/views/shared/config.ts +82 -0
  188. package/src/views/shared/index.ts +6 -0
  189. package/src/views.ts +16 -0
@@ -0,0 +1,294 @@
1
+ 'use client'
2
+
3
+ import React, { useState, useRef, useCallback } from 'react'
4
+ import { useRouter } from 'next/navigation'
5
+ // RichTextEditor must be provided by the host application
6
+ // import { RichTextEditor } from '@/components/RichTextEditor'
7
+ import { useTypingSignal } from './TypingIndicator'
8
+
9
+ const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5 MB
10
+ const MAX_FILE_SIZE_LABEL = '5 Mo'
11
+
12
+ export function TicketReplyForm({ ticketId }: { ticketId: number | string }) {
13
+ const router = useRouter()
14
+ const fileInputRef = useRef<HTMLInputElement>(null)
15
+ const sendTyping = useTypingSignal(Number(ticketId))
16
+ const [body, setBody] = useState('')
17
+ const [bodyHtml, setBodyHtml] = useState('')
18
+ const [editorKey, setEditorKey] = useState(0)
19
+ const [files, setFiles] = useState<File[]>([])
20
+ const [loading, setLoading] = useState(false)
21
+ const [error, setError] = useState('')
22
+ const [isDragging, setIsDragging] = useState(false)
23
+
24
+ const handleEditorFileUpload = useCallback(async (file: File): Promise<string | null> => {
25
+ if (file.size > 5 * 1024 * 1024) return null
26
+ const formData = new FormData()
27
+ formData.append('file', file)
28
+ formData.append('_payload', JSON.stringify({ alt: file.name }))
29
+ try {
30
+ const res = await fetch('/api/media', { method: 'POST', credentials: 'include', body: formData })
31
+ if (res.ok) {
32
+ const data = await res.json()
33
+ return data.doc?.url || null
34
+ }
35
+ } catch { /* ignore */ }
36
+ return null
37
+ }, [])
38
+
39
+ const addFiles = useCallback((newFiles: File[]) => {
40
+ const tooLarge = newFiles.filter((f) => f.size > MAX_FILE_SIZE)
41
+ if (tooLarge.length > 0) {
42
+ setError(`Fichier(s) trop volumineux (max ${MAX_FILE_SIZE_LABEL}) : ${tooLarge.map((f) => f.name).join(', ')}`)
43
+ }
44
+ setFiles((prev) => [...prev, ...newFiles.filter((f) => f.size <= MAX_FILE_SIZE)])
45
+ }, [])
46
+
47
+ const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
48
+ if (e.target.files) {
49
+ addFiles(Array.from(e.target.files))
50
+ }
51
+ // Reset input so same file can be re-selected
52
+ if (fileInputRef.current) fileInputRef.current.value = ''
53
+ }
54
+
55
+ const handleDragOver = useCallback((e: React.DragEvent) => {
56
+ e.preventDefault()
57
+ e.stopPropagation()
58
+ setIsDragging(true)
59
+ }, [])
60
+
61
+ const handleDragLeave = useCallback((e: React.DragEvent) => {
62
+ e.preventDefault()
63
+ e.stopPropagation()
64
+ setIsDragging(false)
65
+ }, [])
66
+
67
+ const handleDrop = useCallback((e: React.DragEvent) => {
68
+ e.preventDefault()
69
+ e.stopPropagation()
70
+ setIsDragging(false)
71
+ if (e.dataTransfer.files?.length) {
72
+ addFiles(Array.from(e.dataTransfer.files))
73
+ }
74
+ }, [addFiles])
75
+
76
+ const removeFile = (index: number) => {
77
+ setFiles((prev) => prev.filter((_, i) => i !== index))
78
+ }
79
+
80
+ const handleSubmit = async (e: React.FormEvent) => {
81
+ e.preventDefault()
82
+ setError('')
83
+
84
+ if (!body.trim() && !bodyHtml) {
85
+ setError('Le message ne peut pas être vide.')
86
+ return
87
+ }
88
+
89
+ setLoading(true)
90
+
91
+ try {
92
+ // Upload files first if any
93
+ const uploadedMediaIds: number[] = []
94
+ for (const file of files) {
95
+ const formData = new FormData()
96
+ formData.append('file', file)
97
+ formData.append('_payload', JSON.stringify({ alt: file.name }))
98
+
99
+ const uploadRes = await fetch('/api/media', {
100
+ method: 'POST',
101
+ credentials: 'include',
102
+ body: formData,
103
+ })
104
+
105
+ if (uploadRes.ok) {
106
+ const uploadData = await uploadRes.json()
107
+ if (uploadData.doc?.id) {
108
+ uploadedMediaIds.push(uploadData.doc.id)
109
+ }
110
+ }
111
+ }
112
+
113
+ // Create message with attachments via Payload REST API
114
+ const finalBody = body.trim() || (bodyHtml ? '[Contenu enrichi]' : '')
115
+ const messageData: Record<string, unknown> = {
116
+ ticket: ticketId,
117
+ body: finalBody,
118
+ ...(bodyHtml ? { bodyHtml } : {}),
119
+ authorType: 'client',
120
+ }
121
+
122
+ if (uploadedMediaIds.length > 0) {
123
+ messageData.attachments = uploadedMediaIds.map((id) => ({ file: id }))
124
+ }
125
+
126
+ const res = await fetch('/api/ticket-messages', {
127
+ method: 'POST',
128
+ headers: { 'Content-Type': 'application/json' },
129
+ credentials: 'include',
130
+ body: JSON.stringify(messageData),
131
+ })
132
+
133
+ if (!res.ok) {
134
+ const data = await res.json()
135
+ setError(data.errors?.[0]?.message || 'Erreur lors de l\'envoi.')
136
+ return
137
+ }
138
+
139
+ setBody('')
140
+ setBodyHtml('')
141
+ setEditorKey((k) => k + 1)
142
+ setFiles([])
143
+ router.refresh()
144
+ } catch {
145
+ setError('Erreur de connexion. Veuillez réessayer.')
146
+ } finally {
147
+ setLoading(false)
148
+ }
149
+ }
150
+
151
+ return (
152
+ <form
153
+ onSubmit={handleSubmit}
154
+ onDragOver={handleDragOver}
155
+ onDragLeave={handleDragLeave}
156
+ onDrop={handleDrop}
157
+ className={`rounded-xl transition-all ${
158
+ isDragging
159
+ ? 'bg-blue-50/50 dark:bg-blue-900/20 ring-2 ring-blue-400/20'
160
+ : ''
161
+ }`}
162
+ >
163
+ <div>
164
+ {error && (
165
+ <div className="mb-4 flex items-center gap-2 rounded-xl border border-red-200 dark:border-red-800/50 bg-red-50 dark:bg-red-900/20 px-4 py-3 text-sm text-red-700 dark:text-red-400">
166
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4 flex-shrink-0">
167
+ <circle cx="12" cy="12" r="10" />
168
+ <line x1="15" y1="9" x2="9" y2="15" />
169
+ <line x1="9" y1="9" x2="15" y2="15" />
170
+ </svg>
171
+ {error}
172
+ </div>
173
+ )}
174
+
175
+ {isDragging && (
176
+ <div className="mb-4 flex items-center justify-center rounded-xl border-2 border-dashed border-blue-300 dark:border-blue-600 bg-blue-50 dark:bg-blue-900/20 p-8">
177
+ <div className="flex items-center gap-2 text-sm font-medium text-blue-600 dark:text-blue-400">
178
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5">
179
+ <path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
180
+ <polyline points="17 8 12 3 7 8" />
181
+ <line x1="12" y1="3" x2="12" y2="15" />
182
+ </svg>
183
+ D&eacute;posez vos fichiers ici
184
+ </div>
185
+ </div>
186
+ )}
187
+
188
+ {/* Message textarea */}
189
+ <div className="mb-4">
190
+ <textarea
191
+ key={editorKey}
192
+ value={body}
193
+ onChange={(e) => { setBody(e.target.value); setBodyHtml(''); sendTyping() }}
194
+ placeholder="Écrivez votre message..."
195
+ rows={5}
196
+ className="w-full resize-y rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 px-4 py-3 text-sm leading-relaxed text-slate-900 dark:text-white placeholder:text-slate-400 dark:placeholder:text-slate-500 outline-none transition-all duration-200 focus:border-blue-500 focus:ring-4 focus:ring-blue-500/10"
197
+ />
198
+ </div>
199
+
200
+ {/* File attachments zone */}
201
+ <div className="flex flex-wrap items-center gap-3">
202
+ <input
203
+ ref={fileInputRef}
204
+ type="file"
205
+ multiple
206
+ onChange={handleFileChange}
207
+ className="hidden"
208
+ accept="image/*,.pdf,.doc,.docx,.txt,.zip,.xls,.xlsx,.csv"
209
+ />
210
+ <button
211
+ type="button"
212
+ onClick={() => fileInputRef.current?.click()}
213
+ className="inline-flex items-center gap-1.5 rounded-lg border border-dashed border-slate-300 dark:border-slate-600 px-3 py-2 text-xs font-medium text-slate-500 dark:text-slate-400 transition-colors hover:border-blue-400 dark:hover:border-blue-500 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-blue-50/50 dark:hover:bg-blue-900/20"
214
+ >
215
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-3.5 w-3.5">
216
+ <path d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
217
+ </svg>
218
+ Joindre (max {MAX_FILE_SIZE_LABEL})
219
+ </button>
220
+
221
+ {/* Send button */}
222
+ <button
223
+ type="submit"
224
+ disabled={loading || (!body.trim() && !bodyHtml)}
225
+ className="ml-auto inline-flex items-center gap-2 rounded-xl bg-blue-600 px-5 py-2.5 text-sm font-semibold text-white shadow-sm transition-all hover:bg-blue-700 hover:shadow-md disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:shadow-sm"
226
+ >
227
+ {loading ? (
228
+ <>
229
+ <svg className="h-4 w-4 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
230
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
231
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
232
+ </svg>
233
+ Envoi...
234
+ </>
235
+ ) : (
236
+ <>
237
+ Envoyer
238
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4">
239
+ <line x1="22" y1="2" x2="11" y2="13" />
240
+ <polygon points="22 2 15 22 11 13 2 9 22 2" />
241
+ </svg>
242
+ </>
243
+ )}
244
+ </button>
245
+ </div>
246
+
247
+ {/* Attached files list */}
248
+ {files.length > 0 && (
249
+ <div className="mt-3 flex flex-wrap gap-2">
250
+ {files.map((file, index) => {
251
+ const isImage = file.type.startsWith('image/')
252
+ return (
253
+ <div
254
+ key={index}
255
+ className="group flex items-center gap-2 rounded-lg border border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800 px-3 py-1.5 text-sm transition-colors hover:border-slate-300 dark:hover:border-slate-600"
256
+ >
257
+ {isImage && (
258
+ // eslint-disable-next-line @next/next/no-img-element
259
+ <img
260
+ src={URL.createObjectURL(file)}
261
+ alt={file.name}
262
+ className="h-7 w-7 rounded object-cover"
263
+ />
264
+ )}
265
+ {!isImage && (
266
+ <svg className="h-4 w-4 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
267
+ <path strokeLinecap="round" strokeLinejoin="round" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
268
+ </svg>
269
+ )}
270
+ <span className="max-w-[120px] truncate text-xs font-medium text-slate-700 dark:text-slate-300">{file.name}</span>
271
+ <span className="text-xs font-mono text-slate-500">
272
+ {file.size < 1024 * 1024
273
+ ? `${(file.size / 1024).toFixed(0)}KB`
274
+ : `${(file.size / (1024 * 1024)).toFixed(1)}MB`}
275
+ </span>
276
+ <button
277
+ type="button"
278
+ onClick={() => removeFile(index)}
279
+ className="ml-0.5 rounded-full p-0.5 text-slate-400 transition-colors hover:bg-red-100 hover:text-red-500 dark:hover:bg-red-900/30 dark:hover:text-red-400"
280
+ >
281
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-3.5 w-3.5">
282
+ <line x1="18" y1="6" x2="6" y2="18" />
283
+ <line x1="6" y1="6" x2="18" y2="18" />
284
+ </svg>
285
+ </button>
286
+ </div>
287
+ )
288
+ })}
289
+ </div>
290
+ )}
291
+ </div>
292
+ </form>
293
+ )
294
+ }
@@ -0,0 +1,58 @@
1
+ 'use client'
2
+
3
+ import { useState, useEffect, useCallback, useRef } from 'react'
4
+
5
+ export function TypingIndicator({ ticketId }: { ticketId: number }) {
6
+ const [typing, setTyping] = useState(false)
7
+ const [name, setName] = useState<string | null>(null)
8
+
9
+ useEffect(() => {
10
+ const poll = async () => {
11
+ try {
12
+ const res = await fetch(`/api/support/typing?ticketId=${ticketId}`, { credentials: 'include' })
13
+ if (res.ok) {
14
+ const data = await res.json()
15
+ setTyping(data.typing)
16
+ setName(data.name)
17
+ }
18
+ } catch { /* silent */ }
19
+ }
20
+
21
+ poll()
22
+ const interval = setInterval(poll, 2000)
23
+ return () => clearInterval(interval)
24
+ }, [ticketId])
25
+
26
+ if (!typing) return null
27
+
28
+ return (
29
+ <div className="flex items-center gap-2 px-4 py-2 text-xs text-slate-400 dark:text-slate-500 animate-pulse">
30
+ <div className="flex gap-0.5">
31
+ <span className="w-1.5 h-1.5 rounded-full bg-blue-400 animate-bounce" style={{ animationDelay: '0ms' }} />
32
+ <span className="w-1.5 h-1.5 rounded-full bg-blue-400 animate-bounce" style={{ animationDelay: '150ms' }} />
33
+ <span className="w-1.5 h-1.5 rounded-full bg-blue-400 animate-bounce" style={{ animationDelay: '300ms' }} />
34
+ </div>
35
+ <span>{name || 'Support'} est en train d&apos;écrire...</span>
36
+ </div>
37
+ )
38
+ }
39
+
40
+ export function useTypingSignal(ticketId: number) {
41
+ const lastSent = useRef(0)
42
+
43
+ const sendTyping = useCallback(() => {
44
+ const now = Date.now()
45
+ // Throttle: send at most every 3s
46
+ if (now - lastSent.current < 3000) return
47
+ lastSent.current = now
48
+
49
+ fetch('/api/support/typing', {
50
+ method: 'POST',
51
+ headers: { 'Content-Type': 'application/json' },
52
+ credentials: 'include',
53
+ body: JSON.stringify({ ticketId }),
54
+ }).catch(() => {})
55
+ }, [ticketId])
56
+
57
+ return sendTyping
58
+ }