@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,738 @@
1
+ import React from 'react'
2
+ import { headers as getHeaders } from 'next/headers'
3
+ import { getPayload } from 'payload'
4
+ import configPromise from '@payload-config'
5
+ import { notFound } from 'next/navigation'
6
+ import Link from 'next/link'
7
+ import { TicketReplyForm } from './TicketReplyForm'
8
+ import { CloseTicketButton } from './CloseTicketButton'
9
+ import { ReopenTicketButton } from './ReopenTicketButton'
10
+ import { SatisfactionForm } from './SatisfactionForm'
11
+ import { CollapsibleMessages } from './CollapsibleMessages'
12
+ import { TicketPolling } from './TicketPolling'
13
+ import { MarkSolutionButton } from './MarkSolutionButton'
14
+ import { PrintButton } from './PrintButton'
15
+ import { TypingIndicator } from './TypingIndicator'
16
+ import { MessageActions, EditedBadge, DeletedMessage } from './MessageActions'
17
+ import { ReadReceipt } from './ReadReceipt'
18
+ // Document type for ticket attachments
19
+ type PayloadDocument = { filename?: string; title?: string; url?: string }
20
+
21
+ const statusConfig: Record<string, { label: string; color: string; bg: string }> = {
22
+ open: { label: 'Ouvert', color: 'text-green-700', bg: 'bg-green-50 text-green-700' },
23
+ waiting_client: { label: 'En attente', color: 'text-amber-700', bg: 'bg-amber-50 text-amber-700' },
24
+ resolved: { label: 'Résolu', color: 'text-blue-700', bg: 'bg-blue-50 text-blue-700' },
25
+ }
26
+
27
+ // Date formatting helpers
28
+ const TZ = 'Europe/Paris'
29
+
30
+ function getDateLabel(dateStr: string): string {
31
+ const date = new Date(dateStr)
32
+ const today = new Date()
33
+ const todayParis = today.toLocaleDateString('fr-FR', { timeZone: TZ })
34
+ const dateParis = date.toLocaleDateString('fr-FR', { timeZone: TZ })
35
+ const yesterday = new Date(today)
36
+ yesterday.setDate(yesterday.getDate() - 1)
37
+ const yesterdayParis = yesterday.toLocaleDateString('fr-FR', { timeZone: TZ })
38
+
39
+ if (dateParis === todayParis) return "Aujourd'hui"
40
+ if (dateParis === yesterdayParis) return 'Hier'
41
+ return date.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short', year: 'numeric', timeZone: TZ })
42
+ }
43
+
44
+ function formatMessageDate(dateStr: string): string {
45
+ const date = new Date(dateStr)
46
+ const today = new Date()
47
+ const todayParis = today.toLocaleDateString('fr-FR', { timeZone: TZ })
48
+ const dateParis = date.toLocaleDateString('fr-FR', { timeZone: TZ })
49
+ const yesterday = new Date(today)
50
+ yesterday.setDate(yesterday.getDate() - 1)
51
+ const yesterdayParis = yesterday.toLocaleDateString('fr-FR', { timeZone: TZ })
52
+ const time = date.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', timeZone: TZ })
53
+
54
+ if (dateParis === todayParis) return time
55
+ if (dateParis === yesterdayParis) return `Hier, ${time}`
56
+ if (date.getFullYear() === today.getFullYear()) {
57
+ return `${date.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short', timeZone: TZ })}, ${time}`
58
+ }
59
+ return `${date.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short', year: 'numeric', timeZone: TZ })}, ${time}`
60
+ }
61
+
62
+ const priorityConfig: Record<string, { label: string; color: string; bg: string }> = {
63
+ low: { label: 'Basse', color: 'text-gray-600', bg: 'bg-gray-100 text-gray-600' },
64
+ normal: { label: 'Normale', color: 'text-blue-600', bg: 'bg-blue-50 text-blue-600' },
65
+ high: { label: 'Haute', color: 'text-orange-600', bg: 'bg-orange-50 text-orange-600' },
66
+ urgent: { label: 'Urgente', color: 'text-red-600', bg: 'bg-red-50 text-red-600' },
67
+ }
68
+
69
+ const categoryLabels: Record<string, string> = {
70
+ bug: 'Bug / Dysfonctionnement',
71
+ content: 'Modification de contenu',
72
+ feature: 'Nouvelle fonctionnalité',
73
+ question: 'Question / Aide',
74
+ hosting: 'Hébergement / Domaine',
75
+ }
76
+
77
+ export default async function TicketDetailPage({ params }: { params: Promise<{ id: string }> }) {
78
+ const { id } = await params
79
+ const payload = await getPayload({ config: configPromise })
80
+ const headers = await getHeaders()
81
+ const { user } = await payload.auth({ headers })
82
+
83
+ if (!user) return null
84
+
85
+ // Fetch the ticket
86
+ let ticket
87
+ try {
88
+ ticket = await payload.findByID({
89
+ collection: 'tickets',
90
+ id,
91
+ depth: 1,
92
+ overrideAccess: false,
93
+ user,
94
+ })
95
+ } catch {
96
+ notFound()
97
+ }
98
+
99
+ if (!ticket) notFound()
100
+
101
+ // Mark as read by updating lastClientReadAt
102
+ if (user.collection === 'support-clients') {
103
+ payload.update({
104
+ collection: 'tickets',
105
+ id: ticket.id,
106
+ data: { lastClientReadAt: new Date().toISOString() },
107
+ overrideAccess: true,
108
+ }).catch(() => {})
109
+ }
110
+
111
+ // Fetch messages for this ticket
112
+ const messages = await payload.find({
113
+ collection: 'ticket-messages',
114
+ where: {
115
+ ticket: { equals: ticket.id },
116
+ },
117
+ sort: 'createdAt',
118
+ limit: 100,
119
+ depth: 1,
120
+ overrideAccess: false,
121
+ user,
122
+ })
123
+
124
+ // Check if satisfaction survey already exists
125
+ let hasSurvey = false
126
+ if (ticket.status === 'resolved') {
127
+ const existingSurvey = await payload.find({
128
+ collection: 'satisfaction-surveys',
129
+ where: { ticket: { equals: ticket.id } },
130
+ limit: 1,
131
+ depth: 0,
132
+ overrideAccess: true,
133
+ })
134
+ hasSurvey = existingSurvey.docs.length > 0
135
+ }
136
+
137
+ const status = statusConfig[ticket.status || 'open']
138
+ const priority = priorityConfig[ticket.priority || 'normal']
139
+ const isClosed = ticket.status === 'resolved'
140
+
141
+ // Compute client initials for avatar
142
+ const clientInitials = (() => {
143
+ const fn = (user as { firstName?: string }).firstName || ''
144
+ const ln = (user as { lastName?: string }).lastName || ''
145
+ return ((fn[0] || '') + (ln[0] || '')).toUpperCase() || '?'
146
+ })()
147
+
148
+ // SLA indicator
149
+ const slaInfo = (() => {
150
+ if (!ticket.createdAt) return null
151
+ const created = new Date(ticket.createdAt)
152
+ const now = new Date()
153
+
154
+ if (ticket.firstResponseAt) {
155
+ const firstResponse = new Date(ticket.firstResponseAt)
156
+ const responseTimeMs = firstResponse.getTime() - created.getTime()
157
+ const responseTimeH = Math.floor(responseTimeMs / (1000 * 60 * 60))
158
+ const responseTimeM = Math.floor((responseTimeMs % (1000 * 60 * 60)) / (1000 * 60))
159
+ return {
160
+ label: 'Temps de réponse',
161
+ value: responseTimeH > 0 ? `${responseTimeH}h${String(responseTimeM).padStart(2, '0')}` : `${responseTimeM}min`,
162
+ resolved: ticket.resolvedAt,
163
+ }
164
+ }
165
+
166
+ // Waiting for first response
167
+ const waitingMs = now.getTime() - created.getTime()
168
+ const waitingH = Math.floor(waitingMs / (1000 * 60 * 60))
169
+ return {
170
+ label: 'En attente depuis',
171
+ value: waitingH < 1 ? 'Moins d\'1h' : waitingH < 24 ? `${waitingH}h` : `${Math.floor(waitingH / 24)}j`,
172
+ resolved: null,
173
+ }
174
+ })()
175
+
176
+ // Sidebar content for XL 2-column layout (ticket metadata)
177
+ const ticketSidebar = (
178
+ <aside className="hidden xl:block">
179
+ <div className="sticky top-4 space-y-4">
180
+ {/* Status & Priority */}
181
+ <div className="rounded-2xl border border-slate-200/80 dark:border-slate-700/80 bg-white dark:bg-slate-800/90 p-4 shadow-sm">
182
+ <h2 className="mb-3 text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Détails</h2>
183
+ <div className="space-y-3">
184
+ <div className="flex items-center justify-between">
185
+ <span className="text-xs text-slate-500 dark:text-slate-400">Statut</span>
186
+ <span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold ${status.bg} dark:bg-opacity-20`}>
187
+ {status.label}
188
+ </span>
189
+ </div>
190
+ <div className="flex items-center justify-between">
191
+ <span className="text-xs text-slate-500 dark:text-slate-400">Priorité</span>
192
+ <span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold ${priority.bg} dark:bg-opacity-20`}>
193
+ {priority.label}
194
+ </span>
195
+ </div>
196
+ {ticket.category && (
197
+ <div className="flex items-center justify-between">
198
+ <span className="text-xs text-slate-500 dark:text-slate-400">Catégorie</span>
199
+ <span className="text-xs font-medium text-slate-700 dark:text-slate-300">{categoryLabels[ticket.category]}</span>
200
+ </div>
201
+ )}
202
+ {slaInfo && (
203
+ <div className="flex items-center justify-between">
204
+ <span className="text-xs text-slate-500 dark:text-slate-400">{slaInfo.label}</span>
205
+ <span className="text-xs font-mono font-medium text-slate-700 dark:text-slate-300">{slaInfo.value}</span>
206
+ </div>
207
+ )}
208
+ {ticket.project && typeof ticket.project === 'object' && (
209
+ <div className="flex items-center justify-between">
210
+ <span className="text-xs text-slate-500 dark:text-slate-400">Projet</span>
211
+ <span className="inline-flex items-center rounded-full bg-cyan-50 dark:bg-cyan-900/30 px-2 py-0.5 text-xs font-medium text-cyan-700 dark:text-cyan-400">
212
+ {ticket.project.name}
213
+ </span>
214
+ </div>
215
+ )}
216
+ <div className="flex items-center justify-between">
217
+ <span className="text-xs text-slate-500 dark:text-slate-400">Créé le</span>
218
+ <span className="text-xs font-mono text-slate-700 dark:text-slate-300">
219
+ {ticket.createdAt
220
+ ? new Date(ticket.createdAt).toLocaleDateString('fr-FR', {
221
+ day: 'numeric',
222
+ month: 'short',
223
+ year: 'numeric',
224
+ timeZone: TZ,
225
+ })
226
+ : ''}
227
+ </span>
228
+ </div>
229
+ </div>
230
+ </div>
231
+
232
+ {/* Documents sidebar */}
233
+ {(ticket.quote || ticket.invoice) && (() => {
234
+ const quoteDoc = typeof ticket.quote === 'object' ? ticket.quote as PayloadDocument : null
235
+ const invoiceDoc = typeof ticket.invoice === 'object' ? ticket.invoice as PayloadDocument : null
236
+ return (
237
+ <div className="rounded-2xl border border-slate-200/80 dark:border-slate-700/80 bg-white dark:bg-slate-800/90 p-4 shadow-sm">
238
+ <h2 className="mb-3 text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Documents</h2>
239
+ <div className="space-y-2">
240
+ {quoteDoc && (
241
+ <a
242
+ href={`${process.env.NEXT_PUBLIC_SERVER_URL || ''}/api/documents/file/${quoteDoc.filename}`}
243
+ target="_blank"
244
+ rel="noopener noreferrer"
245
+ className="flex items-center gap-2 rounded-xl border border-amber-200 dark:border-amber-800/50 bg-amber-50 dark:bg-amber-900/20 px-3 py-2 text-sm font-medium text-amber-800 dark:text-amber-300 transition-all hover:bg-amber-100 dark:hover:bg-amber-900/30"
246
+ >
247
+ <svg className="h-4 w-4 text-amber-600 dark:text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
248
+ <path strokeLinecap="round" strokeLinejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
249
+ </svg>
250
+ <span className="text-xs">Devis</span>
251
+ </a>
252
+ )}
253
+ {invoiceDoc && (
254
+ <a
255
+ href={`${process.env.NEXT_PUBLIC_SERVER_URL || ''}/api/documents/file/${invoiceDoc.filename}`}
256
+ target="_blank"
257
+ rel="noopener noreferrer"
258
+ className="flex items-center gap-2 rounded-xl border border-emerald-200 dark:border-emerald-800/50 bg-emerald-50 dark:bg-emerald-900/20 px-3 py-2 text-sm font-medium text-emerald-800 dark:text-emerald-300 transition-all hover:bg-emerald-100 dark:hover:bg-emerald-900/30"
259
+ >
260
+ <svg className="h-4 w-4 text-emerald-600 dark:text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
261
+ <path strokeLinecap="round" strokeLinejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
262
+ </svg>
263
+ <span className="text-xs">Facture</span>
264
+ {(() => {
265
+ const ps = ticket.paymentStatus
266
+ if (ps === 'paid') return <span className="ml-auto rounded-full bg-green-100 dark:bg-green-900/30 px-2 py-0.5 text-xs font-medium text-green-700 dark:text-green-400">Payé</span>
267
+ if (ps === 'partial') return <span className="ml-auto rounded-full bg-orange-100 dark:bg-orange-900/30 px-2 py-0.5 text-xs font-medium text-orange-700 dark:text-orange-400">Partiel</span>
268
+ return <span className="ml-auto rounded-full bg-red-100 dark:bg-red-900/30 px-2 py-0.5 text-xs font-medium text-red-700 dark:text-red-400">Non payé</span>
269
+ })()}
270
+ </a>
271
+ )}
272
+ </div>
273
+ </div>
274
+ )
275
+ })()}
276
+
277
+ {/* Actions sidebar */}
278
+ <div className="rounded-2xl border border-slate-200/80 dark:border-slate-700/80 bg-white dark:bg-slate-800/90 p-4 shadow-sm">
279
+ <div className="flex flex-col gap-2">
280
+ <PrintButton />
281
+ {!isClosed && (
282
+ <CloseTicketButton ticketId={ticket.id} />
283
+ )}
284
+ </div>
285
+ </div>
286
+ </div>
287
+ </aside>
288
+ )
289
+
290
+ return (
291
+ <div className="mx-auto px-4 sm:px-6 lg:px-8" style={{ maxWidth: 1920, display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) 320px', gap: 32 }}>
292
+ {/* Print styles for PDF export */}
293
+ <style dangerouslySetInnerHTML={{ __html: `
294
+ @media print {
295
+ header, nav, .no-print, button, form, [role="link"] { display: none !important; }
296
+ .print-show { display: block !important; }
297
+ body { background: white !important; }
298
+ * { border-color: #e5e7eb !important; }
299
+ }
300
+ `}} />
301
+
302
+ {/* Main column */}
303
+ <div className="flex flex-col" style={{ height: 'calc(100dvh - 64px)' }}>
304
+ {/* Auto-refresh polling */}
305
+ <TicketPolling ticketId={ticket.id} messageCount={messages.docs.length} />
306
+
307
+ {/* Non-scrollable header area */}
308
+ <div className="flex-shrink-0">
309
+ {/* Breadcrumb */}
310
+ <div className="mb-5">
311
+ <Link
312
+ href="/support/dashboard"
313
+ className="group inline-flex items-center gap-1.5 text-sm font-medium text-slate-500 dark:text-slate-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
314
+ >
315
+ <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 transition-transform group-hover:-translate-x-0.5">
316
+ <polyline points="15 18 9 12 15 6" />
317
+ </svg>
318
+ Retour aux tickets
319
+ </Link>
320
+ </div>
321
+
322
+ {/* Ticket header card */}
323
+ <div className="mb-4 rounded-2xl border border-slate-200/80 dark:border-slate-700/80 bg-white dark:bg-slate-800/90 p-5 sm:p-6 shadow-sm backdrop-blur-sm">
324
+ <div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
325
+ <div className="min-w-0 flex-1">
326
+ {/* Ticket number + badges row */}
327
+ <div className="mb-2.5 flex flex-wrap items-center gap-2">
328
+ <span className="font-mono text-xs text-slate-500 dark:text-slate-400">{ticket.ticketNumber}</span>
329
+ <span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold ${status.bg} dark:bg-opacity-20`}>
330
+ {status.label}
331
+ </span>
332
+ <span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold xl:hidden ${priority.bg} dark:bg-opacity-20`}>
333
+ {priority.label}
334
+ </span>
335
+ {slaInfo && (
336
+ <span className="inline-flex items-center gap-1 rounded-full bg-slate-100 dark:bg-slate-700 px-2.5 py-0.5 text-xs font-medium text-slate-500 dark:text-slate-400 xl:hidden">
337
+ <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 w-3">
338
+ <circle cx="12" cy="12" r="10" />
339
+ <polyline points="12 6 12 12 16 14" />
340
+ </svg>
341
+ <span className="font-mono">{slaInfo.label} : {slaInfo.value}</span>
342
+ </span>
343
+ )}
344
+ </div>
345
+ {/* Title */}
346
+ <h1 className="text-lg font-bold text-slate-900 dark:text-white leading-snug sm:text-xl">{ticket.subject}</h1>
347
+ {/* Metadata -- hidden on XL (shown in sidebar) */}
348
+ <div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-slate-500 dark:text-slate-400 xl:hidden">
349
+ {ticket.category && (
350
+ <span className="inline-flex items-center gap-1">
351
+ <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 w-3">
352
+ <path d="M20.59 13.41l-7.17 7.17a2 2 0 01-2.83 0L2 12V2h10l8.59 8.59a2 2 0 010 2.82z" />
353
+ <line x1="7" y1="7" x2="7.01" y2="7" />
354
+ </svg>
355
+ {categoryLabels[ticket.category]}
356
+ </span>
357
+ )}
358
+ {ticket.project && typeof ticket.project === 'object' && (
359
+ <span className="inline-flex items-center gap-1 rounded-full bg-cyan-50 dark:bg-cyan-900/30 px-2 py-0.5 text-xs font-medium text-cyan-700 dark:text-cyan-400">
360
+ {ticket.project.name}
361
+ </span>
362
+ )}
363
+ <span className="font-mono">
364
+ Créé le{' '}
365
+ {ticket.createdAt
366
+ ? new Date(ticket.createdAt).toLocaleDateString('fr-FR', {
367
+ day: 'numeric',
368
+ month: 'long',
369
+ year: 'numeric',
370
+ hour: '2-digit',
371
+ minute: '2-digit',
372
+ timeZone: TZ,
373
+ })
374
+ : ''}
375
+ </span>
376
+ </div>
377
+ </div>
378
+ {/* Actions -- hidden on XL (shown in sidebar) */}
379
+ <div className="flex flex-shrink-0 items-center gap-2 xl:hidden">
380
+ <PrintButton />
381
+ {!isClosed && (
382
+ <CloseTicketButton ticketId={ticket.id} />
383
+ )}
384
+ </div>
385
+ </div>
386
+ </div>
387
+
388
+ {/* Documents -- visible below XL only (on XL they are in the sidebar) */}
389
+ <div className="xl:hidden">
390
+ {(ticket.quote || ticket.invoice) && (() => {
391
+ const quoteDoc = typeof ticket.quote === 'object' ? ticket.quote as PayloadDocument : null
392
+ const invoiceDoc = typeof ticket.invoice === 'object' ? ticket.invoice as PayloadDocument : null
393
+ return (
394
+ <div className="mb-4 rounded-2xl border border-slate-200/80 dark:border-slate-700/80 bg-white dark:bg-slate-800/90 p-5 shadow-sm">
395
+ <h2 className="mb-3 text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Documents</h2>
396
+ <div className="flex flex-wrap gap-3">
397
+ {quoteDoc && (
398
+ <a
399
+ href={`${process.env.NEXT_PUBLIC_SERVER_URL || ''}/api/documents/file/${quoteDoc.filename}`}
400
+ target="_blank"
401
+ rel="noopener noreferrer"
402
+ className="flex items-center gap-3 rounded-xl border border-amber-200 dark:border-amber-800/50 bg-amber-50 dark:bg-amber-900/20 px-4 py-3 text-sm font-medium text-amber-800 dark:text-amber-300 transition-all hover:bg-amber-100 dark:hover:bg-amber-900/30 hover:shadow-sm"
403
+ >
404
+ <svg className="h-5 w-5 text-amber-600 dark:text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
405
+ <path strokeLinecap="round" strokeLinejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
406
+ </svg>
407
+ <div>
408
+ <div className="text-sm">Devis</div>
409
+ <div className="text-xs font-normal text-slate-500 dark:text-slate-400">{quoteDoc.title || 'PDF'}</div>
410
+ </div>
411
+ <svg className="ml-2 h-4 w-4 text-slate-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
412
+ <path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
413
+ </svg>
414
+ </a>
415
+ )}
416
+ {invoiceDoc && (
417
+ <a
418
+ href={`${process.env.NEXT_PUBLIC_SERVER_URL || ''}/api/documents/file/${invoiceDoc.filename}`}
419
+ target="_blank"
420
+ rel="noopener noreferrer"
421
+ className="flex items-center gap-3 rounded-xl border border-emerald-200 dark:border-emerald-800/50 bg-emerald-50 dark:bg-emerald-900/20 px-4 py-3 text-sm font-medium text-emerald-800 dark:text-emerald-300 transition-all hover:bg-emerald-100 dark:hover:bg-emerald-900/30 hover:shadow-sm"
422
+ >
423
+ <svg className="h-5 w-5 text-emerald-600 dark:text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
424
+ <path strokeLinecap="round" strokeLinejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
425
+ </svg>
426
+ <div>
427
+ <div className="text-sm">Facture</div>
428
+ <div className="text-xs font-normal text-slate-500 dark:text-slate-400">{invoiceDoc.title || 'PDF'}</div>
429
+ </div>
430
+ {(() => {
431
+ const ps = ticket.paymentStatus
432
+ if (ps === 'paid') return <span className="ml-2 rounded-full bg-green-100 dark:bg-green-900/30 px-2.5 py-0.5 text-xs font-medium text-green-700 dark:text-green-400">Payé</span>
433
+ if (ps === 'partial') return <span className="ml-2 rounded-full bg-orange-100 dark:bg-orange-900/30 px-2.5 py-0.5 text-xs font-medium text-orange-700 dark:text-orange-400">Partiel</span>
434
+ return <span className="ml-2 rounded-full bg-red-100 dark:bg-red-900/30 px-2.5 py-0.5 text-xs font-medium text-red-700 dark:text-red-400">Non payé</span>
435
+ })()}
436
+ <svg className="ml-1 h-4 w-4 text-slate-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
437
+ <path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
438
+ </svg>
439
+ </a>
440
+ )}
441
+ </div>
442
+ </div>
443
+ )
444
+ })()}
445
+ </div>
446
+ </div>
447
+
448
+ {/* Scrollable conversation area */}
449
+ <div className="flex-1 min-h-0 overflow-y-auto mb-0 rounded-t-2xl border border-b-0 border-slate-200/80 dark:border-slate-700/80 bg-gradient-to-b from-slate-50/50 to-white dark:from-slate-800/50 dark:to-slate-800/90 shadow-sm">
450
+ {/* Conversation header */}
451
+ <div className="sticky top-0 z-10 border-b border-slate-200/80 dark:border-slate-700/80 bg-white/80 dark:bg-slate-800/80 backdrop-blur-sm px-5 py-3">
452
+ <div className="flex items-center justify-between">
453
+ <h2 className="text-sm font-semibold text-slate-700 dark:text-slate-300">Conversation</h2>
454
+ <span className="text-xs text-slate-500 dark:text-slate-400">{messages.docs.length} message{messages.docs.length !== 1 ? 's' : ''}</span>
455
+ </div>
456
+ </div>
457
+
458
+ {/* Messages area */}
459
+ <div className="px-4 py-5 sm:px-6 space-y-1">
460
+ {/* Automatic acknowledgement for new tickets */}
461
+ {messages.docs.length <= 1 && ticket.createdAt && (Date.now() - new Date(ticket.createdAt).getTime()) < 3600000 && (
462
+ <div className="flex justify-center mb-4">
463
+ <div className="inline-flex items-center gap-2 rounded-full bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800/50 px-4 py-2">
464
+ <div className="flex h-5 w-5 items-center justify-center rounded-full bg-green-500">
465
+ <svg className="h-3 w-3 text-white" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
466
+ <polyline points="20,6 9,17 4,12" />
467
+ </svg>
468
+ </div>
469
+ <div className="text-sm">
470
+ <span className="font-medium text-green-800 dark:text-green-300">Demande enregistr&eacute;e</span>
471
+ <span className="text-green-600 dark:text-green-400"> &mdash; R&eacute;ponse sous 2h en moyenne</span>
472
+ </div>
473
+ </div>
474
+ </div>
475
+ )}
476
+
477
+ {messages.docs.length === 0 ? (
478
+ <div className="flex flex-col items-center justify-center py-16 text-center">
479
+ <div className="mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-slate-100 dark:bg-slate-700">
480
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="h-6 w-6 text-slate-400">
481
+ <path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
482
+ </svg>
483
+ </div>
484
+ <p className="text-sm text-slate-500 dark:text-slate-400">Aucun message pour le moment</p>
485
+ </div>
486
+ ) : (
487
+ <CollapsibleMessages>
488
+ {messages.docs.map((msg, idx) => {
489
+ const isClient = msg.authorType === 'client' || msg.authorType === 'email'
490
+ const authorName = isClient
491
+ ? msg.authorType === 'email'
492
+ ? 'Email'
493
+ : 'Vous'
494
+ : 'Support'
495
+
496
+ // Date separator: check if day changed from previous message
497
+ const prevMsg = idx > 0 ? messages.docs[idx - 1] : null
498
+ const showDateSeparator = msg.createdAt && (!prevMsg?.createdAt || new Date(msg.createdAt).toDateString() !== new Date(prevMsg.createdAt).toDateString())
499
+
500
+ return (
501
+ <React.Fragment key={msg.id}>
502
+ {showDateSeparator && (
503
+ <div className="flex items-center gap-3 py-3 my-1">
504
+ <div className="flex-1 border-t border-slate-200/60 dark:border-slate-700/60" />
505
+ <span className="rounded-full bg-white dark:bg-slate-800 border border-slate-200/80 dark:border-slate-700/80 px-3 py-1 text-xs font-medium font-mono text-slate-500 dark:text-slate-400 whitespace-nowrap shadow-sm">{getDateLabel(msg.createdAt!)}</span>
506
+ <div className="flex-1 border-t border-slate-200/60 dark:border-slate-700/60" />
507
+ </div>
508
+ )}
509
+
510
+ <div className={`flex items-end gap-2.5 mb-3 ${isClient ? 'flex-row-reverse' : 'flex-row'}`}>
511
+ {/* Avatar */}
512
+ {isClient ? (
513
+ <div className="flex-shrink-0 h-6 w-6 sm:h-7 sm:w-7 rounded-full bg-gradient-to-br from-slate-400 to-slate-500 flex items-center justify-center text-white text-xs font-bold shadow-sm">
514
+ {clientInitials}
515
+ </div>
516
+ ) : (
517
+ <div className="flex-shrink-0 h-6 w-6 sm:h-7 sm:w-7 rounded-full bg-gradient-to-br from-blue-500 to-blue-700 flex items-center justify-center text-white text-xs font-bold shadow-sm">
518
+ CW
519
+ </div>
520
+ )}
521
+
522
+ {/* Message bubble */}
523
+ <div className={`group relative max-w-[88%] sm:max-w-[80%] md:max-w-[75%] ${isClient ? 'items-end' : 'items-start'}`}>
524
+ <div
525
+ className={`relative rounded-2xl px-4 py-2.5 ${
526
+ isClient
527
+ ? 'rounded-br-md bg-slate-100 dark:bg-slate-700/80 text-slate-800 dark:text-slate-200'
528
+ : 'rounded-bl-md bg-blue-600 text-white'
529
+ }`}
530
+ >
531
+ {/* Author + time */}
532
+ <div className={`mb-1 flex items-center gap-2 ${isClient ? 'justify-end' : ''}`}>
533
+ <span className={`text-xs font-semibold ${isClient ? 'text-slate-500 dark:text-slate-400' : 'text-blue-100'}`}>
534
+ {authorName}
535
+ </span>
536
+ <span className={`text-xs font-mono ${isClient ? 'text-slate-500 dark:text-slate-400' : 'text-blue-200'}`}>
537
+ {msg.createdAt ? formatMessageDate(msg.createdAt) : ''}
538
+ <EditedBadge editedAt={(msg as unknown as { editedAt?: string }).editedAt} />
539
+ </span>
540
+ </div>
541
+
542
+ {/* Message content */}
543
+ {(msg as unknown as { deletedAt?: string }).deletedAt ? (
544
+ <DeletedMessage />
545
+ ) : msg.bodyHtml ? (
546
+ <div
547
+ className={`text-sm leading-relaxed [&_blockquote]:my-2 [&_blockquote]:border-l-2 [&_blockquote]:pl-3 [&_blockquote]:opacity-80 [&_img]:max-w-full [&_img]:h-auto [&_img]:rounded-lg [&_img]:my-2 [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:list-decimal [&_ol]:pl-5 [&_li]:my-0.5 [&_p]:mb-1 last:[&_p]:mb-0 ${
548
+ isClient
549
+ ? '[&_a]:text-blue-600 dark:[&_a]:text-blue-400 [&_a]:underline [&_blockquote]:border-slate-300 [&_blockquote]:bg-slate-50 dark:[&_blockquote]:bg-slate-600/30 [&_blockquote]:rounded-r-lg [&_blockquote]:py-1 [&_blockquote]:px-3'
550
+ : '[&_a]:text-blue-100 [&_a]:underline [&_blockquote]:border-blue-300 [&_blockquote]:rounded-r-lg [&_blockquote]:py-1 [&_blockquote]:px-3'
551
+ }`}
552
+ dangerouslySetInnerHTML={{ __html: msg.bodyHtml }}
553
+ />
554
+ ) : (
555
+ <div className="whitespace-pre-wrap text-sm leading-relaxed" dangerouslySetInnerHTML={{
556
+ __html: msg.body
557
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
558
+ .replace(/\[code:(\d+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer" class="text-blue-600 underline font-semibold">🔗 Voir le code partagé</a>')
559
+ .replace(/\n/g, '<br/>')
560
+ }} />
561
+ )}
562
+
563
+ {/* Attachments */}
564
+ {Array.isArray(msg.attachments) && msg.attachments.length > 0 && (
565
+ <div className="mt-2.5 space-y-2">
566
+ {/* Inline image previews */}
567
+ {msg.attachments
568
+ .filter((att) => {
569
+ const file = typeof att.file === 'object' ? att.file : null
570
+ return file?.mimeType?.startsWith('image/')
571
+ })
572
+ .map((att, i) => {
573
+ const file = typeof att.file === 'object' ? att.file : null
574
+ if (!file) return null
575
+ const thumbnailUrl = file.sizes?.medium?.url || file.sizes?.small?.url || file.url
576
+ return (
577
+ <a
578
+ key={`img-${i}`}
579
+ href={file.url || '#'}
580
+ target="_blank"
581
+ rel="noopener noreferrer"
582
+ className="block overflow-hidden rounded-xl border border-white/20 transition-opacity hover:opacity-90"
583
+ >
584
+ {/* eslint-disable-next-line @next/next/no-img-element */}
585
+ <img
586
+ src={thumbnailUrl || file.url || ''}
587
+ alt={file.alt || file.filename || 'Image jointe'}
588
+ className="max-h-72 w-auto object-contain rounded-xl"
589
+ loading="lazy"
590
+ />
591
+ </a>
592
+ )
593
+ })}
594
+ {/* Video previews */}
595
+ {msg.attachments
596
+ .filter((att) => {
597
+ const file = typeof att.file === 'object' ? att.file : null
598
+ return file?.mimeType?.startsWith('video/')
599
+ })
600
+ .map((att, i) => {
601
+ const file = typeof att.file === 'object' ? att.file : null
602
+ if (!file) return null
603
+ return (
604
+ <video
605
+ key={`vid-${i}`}
606
+ src={file.url || ''}
607
+ controls
608
+ preload="metadata"
609
+ className="max-h-52 max-w-sm rounded-xl bg-black"
610
+ />
611
+ )
612
+ })}
613
+ {/* Non-media file links */}
614
+ <div className="flex flex-wrap gap-1.5">
615
+ {msg.attachments
616
+ .filter((att) => {
617
+ const file = typeof att.file === 'object' ? att.file : null
618
+ return file && !file.mimeType?.startsWith('image/') && !file.mimeType?.startsWith('video/')
619
+ })
620
+ .map((att, i) => {
621
+ const file = typeof att.file === 'object' ? att.file : null
622
+ if (!file) return null
623
+ return (
624
+ <a
625
+ key={`file-${i}`}
626
+ href={file.url || '#'}
627
+ target="_blank"
628
+ rel="noopener noreferrer"
629
+ className={`inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium transition-colors ${
630
+ isClient
631
+ ? 'bg-slate-200 dark:bg-slate-600 text-slate-700 dark:text-slate-300 hover:bg-slate-300 dark:hover:bg-slate-500'
632
+ : 'bg-blue-500/30 text-blue-50 hover:bg-blue-500/50'
633
+ }`}
634
+ >
635
+ <svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
636
+ <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" />
637
+ </svg>
638
+ {file.filename || 'Fichier'}
639
+ </a>
640
+ )
641
+ })}
642
+ </div>
643
+ </div>
644
+ )}
645
+ </div>
646
+
647
+ {/* Actions below bubble */}
648
+ <div className={`mt-1 flex items-center gap-1.5 ${isClient ? 'justify-end' : 'justify-start'}`}>
649
+ {/* Edit/delete actions for client messages */}
650
+ {isClient && msg.authorType === 'client' && !(msg as unknown as { deletedAt?: string }).deletedAt && (
651
+ <MessageActions
652
+ messageId={msg.id}
653
+ body={msg.body}
654
+ createdAt={msg.createdAt}
655
+ />
656
+ )}
657
+ {/* Read receipt on last client message */}
658
+ {isClient && idx === messages.docs.length - 1 && (
659
+ <ReadReceipt
660
+ lastAdminReadAt={ticket.lastAdminReadAt as string | undefined}
661
+ messageCreatedAt={msg.createdAt}
662
+ />
663
+ )}
664
+ {/* Solution badge / mark button */}
665
+ {msg.isSolution && (
666
+ <div className="inline-flex items-center gap-1 rounded-full bg-emerald-100 dark:bg-emerald-900/40 px-2.5 py-1 text-xs font-semibold text-emerald-700 dark:text-emerald-400 shadow-sm border border-emerald-200 dark:border-emerald-800/50">
667
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="h-3 w-3">
668
+ <polyline points="20,6 9,17 4,12" />
669
+ </svg>
670
+ Solution
671
+ </div>
672
+ )}
673
+ {!isClient && !msg.isSolution && !isClosed && (
674
+ <div className="opacity-0 group-hover:opacity-100 transition-opacity">
675
+ <MarkSolutionButton messageId={msg.id} isSolution={false} />
676
+ </div>
677
+ )}
678
+ </div>
679
+ </div>
680
+ </div>
681
+ </React.Fragment>
682
+ )
683
+ })}
684
+ </CollapsibleMessages>
685
+ )}
686
+ </div>
687
+
688
+ {/* Typing indicator inside conversation */}
689
+ <div className="px-4 sm:px-6 pb-2">
690
+ <TypingIndicator ticketId={ticket.id} />
691
+ </div>
692
+ </div>
693
+
694
+ {/* Sticky reply form or resolved state at bottom */}
695
+ <div className="flex-shrink-0 border-t border-slate-200/80 dark:border-slate-700/80 bg-white dark:bg-slate-900 px-4 py-3">
696
+ {!isClosed ? (
697
+ <TicketReplyForm ticketId={ticket.id} />
698
+ ) : (
699
+ <div className="space-y-4">
700
+ {/* Resolved card */}
701
+ <div className="rounded-2xl border border-emerald-200 dark:border-emerald-800/50 bg-gradient-to-br from-emerald-50 to-green-50 dark:from-emerald-900/20 dark:to-green-900/20 p-6 text-center">
702
+ <div className="mx-auto mb-3 flex h-11 w-11 items-center justify-center rounded-full bg-emerald-500 shadow-lg shadow-emerald-500/20">
703
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5 text-white">
704
+ <polyline points="20,6 9,17 4,12" />
705
+ </svg>
706
+ </div>
707
+ <p className="text-base font-bold text-emerald-900 dark:text-emerald-200">Ce ticket est résolu</p>
708
+ <p className="mt-1 text-sm text-emerald-600 dark:text-emerald-400">
709
+ Un souci persiste ?
710
+ </p>
711
+ <div className="mt-5 flex flex-wrap items-center justify-center gap-3">
712
+ <ReopenTicketButton ticketId={ticket.id} />
713
+ <Link
714
+ href="/support/tickets/new"
715
+ className="inline-flex items-center gap-2 rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 px-4 py-2.5 text-sm font-semibold text-slate-700 dark:text-slate-300 transition-all hover:bg-slate-50 dark:hover:bg-slate-700 hover:shadow-sm"
716
+ >
717
+ <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">
718
+ <line x1="12" y1="5" x2="12" y2="19" />
719
+ <line x1="5" y1="12" x2="19" y2="12" />
720
+ </svg>
721
+ Nouveau ticket
722
+ </Link>
723
+ </div>
724
+ </div>
725
+ {/* Satisfaction survey */}
726
+ {!hasSurvey && user.collection === 'support-clients' && (
727
+ <SatisfactionForm ticketId={ticket.id} />
728
+ )}
729
+ </div>
730
+ )}
731
+ </div>
732
+ </div>
733
+
734
+ {/* XL sidebar */}
735
+ {ticketSidebar}
736
+ </div>
737
+ )
738
+ }