@consilioweb/payload-support 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/components/TicketConversation/locales/en.json +25 -1
  2. package/dist/components/TicketConversation/locales/fr.json +25 -1
  3. package/dist/index.cjs +799 -4
  4. package/dist/index.d.cts +15 -1
  5. package/dist/index.d.ts +15 -1
  6. package/dist/index.js +800 -6
  7. package/dist/styles/TicketDetail.module.scss +294 -0
  8. package/dist/views/TicketDetailView/client.cjs +455 -249
  9. package/dist/views/TicketDetailView/client.js +456 -250
  10. package/dist/views/TicketInboxView/client.cjs +10 -9
  11. package/dist/views/TicketInboxView/client.js +2 -1
  12. package/package.json +1 -1
  13. package/src/collections/ClientSummaries.ts +16 -0
  14. package/src/collections/TicketCollaborators.ts +93 -0
  15. package/src/collections/TicketFeedback.ts +116 -0
  16. package/src/collections/TicketMessages.ts +9 -0
  17. package/src/collections/Tickets.ts +31 -1
  18. package/src/collections/index.ts +2 -0
  19. package/src/components/TicketConversation/locales/en.json +25 -1
  20. package/src/components/TicketConversation/locales/fr.json +25 -1
  21. package/src/endpoints/escalate.ts +44 -0
  22. package/src/endpoints/index.ts +15 -0
  23. package/src/endpoints/invite-collaborator.ts +215 -0
  24. package/src/endpoints/kb-search.ts +156 -0
  25. package/src/endpoints/ticket-feedback.ts +104 -0
  26. package/src/endpoints/transfer-ticket.ts +248 -0
  27. package/src/index.ts +1 -0
  28. package/src/plugin.ts +4 -0
  29. package/src/portal/auth/ChatWidget.tsx +11 -0
  30. package/src/portal/auth/dashboard/DashboardClient.tsx +85 -99
  31. package/src/portal/auth/dashboard/page.tsx +11 -2
  32. package/src/portal/auth/tickets/detail/TransferAndInviteActions.tsx +402 -0
  33. package/src/portal/auth/tickets/detail/page.tsx +122 -23
  34. package/src/portal/auth/tickets/new/KbDeflection.tsx +128 -0
  35. package/src/portal/auth/tickets/new/page.tsx +203 -100
  36. package/src/portal/locales/en.json +5 -0
  37. package/src/portal/locales/fr.json +5 -0
  38. package/src/styles/TicketDetail.module.scss +294 -0
  39. package/src/types.ts +19 -0
  40. package/src/utils/slugs.ts +2 -0
  41. package/src/views/TicketDetailView/client.tsx +346 -89
  42. package/src/views/TicketInboxView/client.tsx +2 -1
@@ -0,0 +1,402 @@
1
+ 'use client'
2
+
3
+ import React, { useCallback, useEffect, useRef, useState } from 'react'
4
+
5
+ type Toast = { type: 'success' | 'error'; message: string } | null
6
+ type Modal = 'transfer' | 'invite' | null
7
+
8
+ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
9
+
10
+ interface Props {
11
+ ticketId: number | string
12
+ }
13
+
14
+ /**
15
+ * Sidebar actions on the ticket detail page:
16
+ * - Transferer par email → POST /api/support/tickets/:id/transfer
17
+ * - Inviter un collegue → POST /api/support/tickets/:id/invite
18
+ *
19
+ * Each action opens a modal (focus trap + ESC to close) with a small form
20
+ * and a toast for feedback.
21
+ */
22
+ export function TransferAndInviteActions({ ticketId }: Props) {
23
+ const [modal, setModal] = useState<Modal>(null)
24
+ const [toast, setToast] = useState<Toast>(null)
25
+
26
+ useEffect(() => {
27
+ if (!toast) return
28
+ const t = setTimeout(() => setToast(null), 3500)
29
+ return () => clearTimeout(t)
30
+ }, [toast])
31
+
32
+ const closeModal = useCallback(() => setModal(null), [])
33
+
34
+ return (
35
+ <>
36
+ <button
37
+ type="button"
38
+ onClick={() => setModal('transfer')}
39
+ className="inline-flex w-full items-center gap-2 rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 px-3 py-2 text-sm font-medium text-slate-700 dark:text-slate-300 transition-colors hover:bg-slate-50 dark:hover:bg-slate-700"
40
+ >
41
+ <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" aria-hidden>
42
+ <path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
43
+ <polyline points="22,6 12,13 2,6" />
44
+ </svg>
45
+ Transf&eacute;rer par email
46
+ </button>
47
+ <button
48
+ type="button"
49
+ onClick={() => setModal('invite')}
50
+ className="inline-flex w-full items-center gap-2 rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 px-3 py-2 text-sm font-medium text-slate-700 dark:text-slate-300 transition-colors hover:bg-slate-50 dark:hover:bg-slate-700"
51
+ >
52
+ <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" aria-hidden>
53
+ <path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
54
+ <circle cx="8.5" cy="7" r="4" />
55
+ <line x1="20" y1="8" x2="20" y2="14" />
56
+ <line x1="23" y1="11" x2="17" y2="11" />
57
+ </svg>
58
+ Inviter un coll&egrave;gue
59
+ </button>
60
+
61
+ {modal === 'transfer' && (
62
+ <TransferModal
63
+ ticketId={ticketId}
64
+ onClose={closeModal}
65
+ onResult={(t) => {
66
+ setToast(t)
67
+ if (t.type === 'success') closeModal()
68
+ }}
69
+ />
70
+ )}
71
+ {modal === 'invite' && (
72
+ <InviteModal
73
+ ticketId={ticketId}
74
+ onClose={closeModal}
75
+ onResult={(t) => {
76
+ setToast(t)
77
+ if (t.type === 'success') closeModal()
78
+ }}
79
+ />
80
+ )}
81
+
82
+ {toast && (
83
+ <div
84
+ role="status"
85
+ aria-live="polite"
86
+ className={`fixed bottom-6 right-6 z-50 inline-flex items-center gap-2 rounded-xl border px-4 py-2.5 text-sm font-medium shadow-lg ${
87
+ toast.type === 'success'
88
+ ? 'border-emerald-200 bg-white text-emerald-800 dark:border-emerald-700/50 dark:bg-slate-800 dark:text-emerald-200'
89
+ : 'border-red-200 bg-white text-red-800 dark:border-red-700/50 dark:bg-slate-800 dark:text-red-200'
90
+ }`}
91
+ >
92
+ {toast.type === 'success' ? (
93
+ <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 text-emerald-500" aria-hidden>
94
+ <polyline points="20,6 9,17 4,12" />
95
+ </svg>
96
+ ) : (
97
+ <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 text-red-500" aria-hidden>
98
+ <circle cx="12" cy="12" r="10" />
99
+ <line x1="12" y1="8" x2="12" y2="12" />
100
+ <line x1="12" y1="16" x2="12.01" y2="16" />
101
+ </svg>
102
+ )}
103
+ {toast.message}
104
+ </div>
105
+ )}
106
+ </>
107
+ )
108
+ }
109
+
110
+ // ─── Modale Transferer ───────────────────────────────────
111
+
112
+ function TransferModal({
113
+ ticketId,
114
+ onClose,
115
+ onResult,
116
+ }: {
117
+ ticketId: number | string
118
+ onClose: () => void
119
+ onResult: (t: Toast) => void
120
+ }) {
121
+ const [email, setEmail] = useState('')
122
+ const [includeAttachments, setIncludeAttachments] = useState(true)
123
+ const [message, setMessage] = useState('')
124
+ const [loading, setLoading] = useState(false)
125
+
126
+ const submit = async (e: React.FormEvent) => {
127
+ e.preventDefault()
128
+ const cleaned = email.trim().toLowerCase()
129
+ if (!cleaned || !EMAIL_RE.test(cleaned)) {
130
+ onResult({ type: 'error', message: 'Email invalide' })
131
+ return
132
+ }
133
+ setLoading(true)
134
+ try {
135
+ const res = await fetch(`/api/support/tickets/${ticketId}/transfer`, {
136
+ method: 'POST',
137
+ credentials: 'include',
138
+ headers: { 'Content-Type': 'application/json' },
139
+ body: JSON.stringify({ email: cleaned, includeAttachments, message }),
140
+ })
141
+ const data = await res.json().catch(() => ({}))
142
+ if (!res.ok) {
143
+ onResult({ type: 'error', message: (data && data.error) || 'Echec du transfert' })
144
+ } else {
145
+ onResult({ type: 'success', message: `Transfert envoye a ${cleaned}` })
146
+ }
147
+ } catch (err) {
148
+ console.warn('[TransferModal] Network error:', err)
149
+ onResult({ type: 'error', message: 'Erreur reseau' })
150
+ } finally {
151
+ setLoading(false)
152
+ }
153
+ }
154
+
155
+ return (
156
+ <Modal title="Transferer par email" onClose={onClose}>
157
+ <form onSubmit={submit} className="flex flex-col gap-4">
158
+ <label className="flex flex-col gap-1.5 text-sm">
159
+ <span className="font-medium text-slate-700 dark:text-slate-300">Destinataire</span>
160
+ <input
161
+ type="email"
162
+ required
163
+ autoFocus
164
+ value={email}
165
+ onChange={(e) => setEmail(e.target.value)}
166
+ placeholder="collegue@exemple.com"
167
+ className="rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 px-3 py-2 text-sm text-slate-900 dark:text-slate-100 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
168
+ />
169
+ </label>
170
+ <label className="flex items-start gap-2 text-sm">
171
+ <input
172
+ type="checkbox"
173
+ checked={includeAttachments}
174
+ onChange={(e) => setIncludeAttachments(e.target.checked)}
175
+ className="mt-0.5 h-4 w-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500"
176
+ />
177
+ <span className="text-slate-700 dark:text-slate-300">
178
+ Inclure les pieces jointes (en liens cliquables)
179
+ </span>
180
+ </label>
181
+ <label className="flex flex-col gap-1.5 text-sm">
182
+ <span className="font-medium text-slate-700 dark:text-slate-300">Message (optionnel)</span>
183
+ <textarea
184
+ value={message}
185
+ onChange={(e) => setMessage(e.target.value)}
186
+ rows={3}
187
+ maxLength={1000}
188
+ placeholder="Un mot pour le destinataire..."
189
+ className="rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 px-3 py-2 text-sm text-slate-900 dark:text-slate-100 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 resize-none"
190
+ />
191
+ </label>
192
+ <ModalFooter onClose={onClose} loading={loading} submitLabel="Envoyer" />
193
+ </form>
194
+ </Modal>
195
+ )
196
+ }
197
+
198
+ // ─── Modale Inviter ──────────────────────────────────────
199
+
200
+ function InviteModal({
201
+ ticketId,
202
+ onClose,
203
+ onResult,
204
+ }: {
205
+ ticketId: number | string
206
+ onClose: () => void
207
+ onResult: (t: Toast) => void
208
+ }) {
209
+ const [email, setEmail] = useState('')
210
+ const [role, setRole] = useState<'viewer' | 'collaborator'>('viewer')
211
+ const [loading, setLoading] = useState(false)
212
+
213
+ const submit = async (e: React.FormEvent) => {
214
+ e.preventDefault()
215
+ const cleaned = email.trim().toLowerCase()
216
+ if (!cleaned || !EMAIL_RE.test(cleaned)) {
217
+ onResult({ type: 'error', message: 'Email invalide' })
218
+ return
219
+ }
220
+ setLoading(true)
221
+ try {
222
+ const res = await fetch(`/api/support/tickets/${ticketId}/invite`, {
223
+ method: 'POST',
224
+ credentials: 'include',
225
+ headers: { 'Content-Type': 'application/json' },
226
+ body: JSON.stringify({ email: cleaned, role }),
227
+ })
228
+ const data = await res.json().catch(() => ({}))
229
+ if (!res.ok) {
230
+ onResult({ type: 'error', message: (data && data.error) || "Echec de l'invitation" })
231
+ } else {
232
+ onResult({ type: 'success', message: `Invitation envoyee a ${cleaned}` })
233
+ }
234
+ } catch (err) {
235
+ console.warn('[InviteModal] Network error:', err)
236
+ onResult({ type: 'error', message: 'Erreur reseau' })
237
+ } finally {
238
+ setLoading(false)
239
+ }
240
+ }
241
+
242
+ return (
243
+ <Modal title="Inviter un collegue" onClose={onClose}>
244
+ <form onSubmit={submit} className="flex flex-col gap-4">
245
+ <label className="flex flex-col gap-1.5 text-sm">
246
+ <span className="font-medium text-slate-700 dark:text-slate-300">Email du collegue</span>
247
+ <input
248
+ type="email"
249
+ required
250
+ autoFocus
251
+ value={email}
252
+ onChange={(e) => setEmail(e.target.value)}
253
+ placeholder="collegue@exemple.com"
254
+ className="rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 px-3 py-2 text-sm text-slate-900 dark:text-slate-100 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
255
+ />
256
+ </label>
257
+ <label className="flex flex-col gap-1.5 text-sm">
258
+ <span className="font-medium text-slate-700 dark:text-slate-300">Role</span>
259
+ <select
260
+ value={role}
261
+ onChange={(e) => setRole(e.target.value as 'viewer' | 'collaborator')}
262
+ className="rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 px-3 py-2 text-sm text-slate-900 dark:text-slate-100 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
263
+ >
264
+ <option value="viewer">Lecteur (consultation seule)</option>
265
+ <option value="collaborator">Collaborateur (peut repondre)</option>
266
+ </select>
267
+ </label>
268
+ <ModalFooter onClose={onClose} loading={loading} submitLabel="Inviter" />
269
+ </form>
270
+ </Modal>
271
+ )
272
+ }
273
+
274
+ // ─── Modal shell (focus trap + ESC) ──────────────────────
275
+
276
+ function Modal({
277
+ title,
278
+ onClose,
279
+ children,
280
+ }: {
281
+ title: string
282
+ onClose: () => void
283
+ children: React.ReactNode
284
+ }) {
285
+ const containerRef = useRef<HTMLDivElement | null>(null)
286
+ const lastFocusedRef = useRef<HTMLElement | null>(null)
287
+
288
+ useEffect(() => {
289
+ lastFocusedRef.current = document.activeElement as HTMLElement | null
290
+
291
+ const onKey = (e: KeyboardEvent) => {
292
+ if (e.key === 'Escape') {
293
+ e.preventDefault()
294
+ onClose()
295
+ return
296
+ }
297
+ if (e.key === 'Tab') {
298
+ const root = containerRef.current
299
+ if (!root) return
300
+ const focusables = root.querySelectorAll<HTMLElement>(
301
+ 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
302
+ )
303
+ if (focusables.length === 0) return
304
+ const first = focusables[0]
305
+ const last = focusables[focusables.length - 1]
306
+ if (e.shiftKey && document.activeElement === first) {
307
+ e.preventDefault()
308
+ last.focus()
309
+ } else if (!e.shiftKey && document.activeElement === last) {
310
+ e.preventDefault()
311
+ first.focus()
312
+ }
313
+ }
314
+ }
315
+
316
+ document.addEventListener('keydown', onKey)
317
+ // Prevent background scroll
318
+ const previousOverflow = document.body.style.overflow
319
+ document.body.style.overflow = 'hidden'
320
+
321
+ return () => {
322
+ document.removeEventListener('keydown', onKey)
323
+ document.body.style.overflow = previousOverflow
324
+ try {
325
+ lastFocusedRef.current?.focus()
326
+ } catch {
327
+ /* noop */
328
+ }
329
+ }
330
+ }, [onClose])
331
+
332
+ return (
333
+ <div
334
+ role="dialog"
335
+ aria-modal="true"
336
+ aria-label={title}
337
+ className="fixed inset-0 z-[100] flex items-center justify-center p-4"
338
+ >
339
+ <div
340
+ className="absolute inset-0 bg-slate-900/50 backdrop-blur-sm"
341
+ onClick={onClose}
342
+ aria-hidden
343
+ />
344
+ <div
345
+ ref={containerRef}
346
+ className="relative w-full max-w-md rounded-2xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 p-6 shadow-2xl"
347
+ >
348
+ <div className="mb-4 flex items-start justify-between">
349
+ <h3 className="text-base font-semibold text-slate-900 dark:text-slate-100">{title}</h3>
350
+ <button
351
+ type="button"
352
+ onClick={onClose}
353
+ aria-label="Fermer"
354
+ className="rounded-md p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-600 dark:hover:bg-slate-800 dark:hover:text-slate-300"
355
+ >
356
+ <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" aria-hidden>
357
+ <line x1="18" y1="6" x2="6" y2="18" />
358
+ <line x1="6" y1="6" x2="18" y2="18" />
359
+ </svg>
360
+ </button>
361
+ </div>
362
+ {children}
363
+ </div>
364
+ </div>
365
+ )
366
+ }
367
+
368
+ function ModalFooter({
369
+ onClose,
370
+ loading,
371
+ submitLabel,
372
+ }: {
373
+ onClose: () => void
374
+ loading: boolean
375
+ submitLabel: string
376
+ }) {
377
+ return (
378
+ <div className="mt-2 flex items-center justify-end gap-2">
379
+ <button
380
+ type="button"
381
+ onClick={onClose}
382
+ disabled={loading}
383
+ className="rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 px-3 py-2 text-sm font-medium text-slate-700 dark:text-slate-300 transition-colors hover:bg-slate-50 dark:hover:bg-slate-700 disabled:opacity-50"
384
+ >
385
+ Annuler
386
+ </button>
387
+ <button
388
+ type="submit"
389
+ disabled={loading}
390
+ className="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-blue-700 disabled:opacity-50"
391
+ >
392
+ {loading && (
393
+ <svg className="h-3.5 w-3.5 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" aria-hidden>
394
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
395
+ <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" />
396
+ </svg>
397
+ )}
398
+ {submitLabel}
399
+ </button>
400
+ </div>
401
+ )
402
+ }
@@ -15,6 +15,7 @@ import { PrintButton } from './PrintButton'
15
15
  import { TypingIndicator } from './TypingIndicator'
16
16
  import { MessageActions, EditedBadge, DeletedMessage } from './MessageActions'
17
17
  import { ReadReceipt } from './ReadReceipt'
18
+ import { TransferAndInviteActions } from './TransferAndInviteActions'
18
19
  // Document type for ticket attachments
19
20
  type PayloadDocument = { filename?: string; title?: string; url?: string }
20
21
 
@@ -138,6 +139,20 @@ export default async function TicketDetailPage({ params }: { params: Promise<{ i
138
139
  const priority = priorityConfig[ticket.priority || 'normal']
139
140
  const isClosed = ticket.status === 'resolved'
140
141
 
142
+ // Compute the "next-step" banner: resolved → done, last msg from admin → client to play,
143
+ // last msg from client/email → support to play.
144
+ const lastMsg = messages.docs.length > 0 ? messages.docs[messages.docs.length - 1] : null
145
+ const lastMsgFromClient = lastMsg ? (lastMsg.authorType === 'client' || lastMsg.authorType === 'email') : false
146
+ const nextStep: 'done' | 'us' | 'you' = isClosed
147
+ ? 'done'
148
+ : lastMsgFromClient
149
+ ? 'us'
150
+ : 'you'
151
+
152
+ // Configurable support phone for urgent help (env var with sane fallback).
153
+ const SUPPORT_PHONE = process.env.NEXT_PUBLIC_SUPPORT_PHONE || '01 23 45 67 89'
154
+ const SUPPORT_PHONE_HREF = SUPPORT_PHONE.replace(/\s+/g, '')
155
+
141
156
  // Compute client initials for avatar
142
157
  const clientInitials = (() => {
143
158
  const fn = (user as { firstName?: string }).firstName || ''
@@ -274,13 +289,37 @@ export default async function TicketDetailPage({ params }: { params: Promise<{ i
274
289
  )
275
290
  })()}
276
291
 
292
+ {/* Urgent contact — surfaced when a phone is configured */}
293
+ <div className="rounded-2xl border border-amber-200 dark:border-amber-900/60 bg-amber-50 dark:bg-amber-950/30 p-4 shadow-sm">
294
+ <h2 className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-amber-700 dark:text-amber-300">
295
+ <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" aria-hidden>
296
+ <path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z" />
297
+ </svg>
298
+ Besoin urgent ?
299
+ </h2>
300
+ <p className="mb-3 text-xs text-amber-800 dark:text-amber-200">
301
+ Pour un site indisponible ou un blocage critique, appelez Christophe directement.
302
+ </p>
303
+ <a
304
+ href={`tel:${SUPPORT_PHONE_HREF}`}
305
+ className="inline-flex w-full items-center justify-center gap-2 rounded-xl border-2 border-amber-500 bg-white px-3 py-2.5 text-sm font-bold text-amber-700 transition-colors hover:bg-amber-100 dark:bg-amber-900/30 dark:text-amber-200 dark:hover:bg-amber-900/50"
306
+ >
307
+ <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-4 w-4" aria-hidden>
308
+ <path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z" />
309
+ </svg>
310
+ {SUPPORT_PHONE}
311
+ </a>
312
+ </div>
313
+
277
314
  {/* Actions sidebar */}
278
315
  <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">
316
+ <h2 className="mb-3 text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Actions</h2>
279
317
  <div className="flex flex-col gap-2">
280
- <PrintButton />
281
318
  {!isClosed && (
282
319
  <CloseTicketButton ticketId={ticket.id} />
283
320
  )}
321
+ <PrintButton />
322
+ <TransferAndInviteActions ticketId={ticket.id} />
284
323
  </div>
285
324
  </div>
286
325
  </div>
@@ -383,6 +422,67 @@ export default async function TicketDetailPage({ params }: { params: Promise<{ i
383
422
  )}
384
423
  </div>
385
424
  </div>
425
+
426
+ {/* Next-step banner: orients the client on who's expected to act next */}
427
+ <div className={`mt-4 flex items-center gap-3 rounded-xl border px-4 py-3 ${
428
+ nextStep === 'done'
429
+ ? 'border-emerald-200 bg-emerald-50 dark:border-emerald-900/60 dark:bg-emerald-950/30'
430
+ : nextStep === 'us'
431
+ ? 'border-amber-200 bg-amber-50 dark:border-amber-900/60 dark:bg-amber-950/30'
432
+ : 'border-blue-200 bg-blue-50 dark:border-blue-900/60 dark:bg-blue-950/30'
433
+ }`}>
434
+ <span className={`flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full bg-white shadow-sm ${
435
+ nextStep === 'done'
436
+ ? 'text-emerald-600 dark:bg-emerald-900/50 dark:text-emerald-400'
437
+ : nextStep === 'us'
438
+ ? 'text-amber-600 dark:bg-amber-900/50 dark:text-amber-400'
439
+ : 'text-blue-600 dark:bg-blue-900/50 dark:text-blue-400'
440
+ }`}>
441
+ {nextStep === 'done' ? (
442
+ <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-4 w-4" aria-hidden>
443
+ <polyline points="20,6 9,17 4,12" />
444
+ </svg>
445
+ ) : nextStep === 'us' ? (
446
+ <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" aria-hidden>
447
+ <circle cx="12" cy="12" r="10" />
448
+ <polyline points="12 6 12 12 16 14" />
449
+ </svg>
450
+ ) : (
451
+ <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" aria-hidden>
452
+ <line x1="12" y1="19" x2="12" y2="5" />
453
+ <polyline points="5 12 12 5 19 12" />
454
+ </svg>
455
+ )}
456
+ </span>
457
+ <div className="min-w-0 flex-1">
458
+ <p className={`text-sm font-semibold ${
459
+ nextStep === 'done'
460
+ ? 'text-emerald-900 dark:text-emerald-200'
461
+ : nextStep === 'us'
462
+ ? 'text-amber-900 dark:text-amber-200'
463
+ : 'text-blue-900 dark:text-blue-200'
464
+ }`}>
465
+ {nextStep === 'done'
466
+ ? 'Ticket resolu'
467
+ : nextStep === 'us'
468
+ ? 'Christophe revient vers vous'
469
+ : 'On attend votre reponse'}
470
+ </p>
471
+ <p className={`text-xs ${
472
+ nextStep === 'done'
473
+ ? 'text-emerald-700 dark:text-emerald-300'
474
+ : nextStep === 'us'
475
+ ? 'text-amber-700 dark:text-amber-300'
476
+ : 'text-blue-700 dark:text-blue-300'
477
+ }`}>
478
+ {nextStep === 'done'
479
+ ? 'N’hesitez pas a rouvrir le ticket si necessaire.'
480
+ : nextStep === 'us'
481
+ ? 'Premiere reponse sous 2h ouvrees en moyenne.'
482
+ : 'Le support attend votre retour pour avancer.'}
483
+ </p>
484
+ </div>
485
+ </div>
386
486
  </div>
387
487
 
388
488
  {/* Documents -- visible below XL only (on XL they are in the sidebar) */}
@@ -508,32 +608,39 @@ export default async function TicketDetailPage({ params }: { params: Promise<{ i
508
608
  )}
509
609
 
510
610
  <div className={`flex items-end gap-2.5 mb-3 ${isClient ? 'flex-row-reverse' : 'flex-row'}`}>
511
- {/* Avatar */}
611
+ {/* Avatar — stays on the same side as its bubble */}
512
612
  {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">
613
+ <div className="flex-shrink-0 h-6 w-6 sm:h-7 sm:w-7 rounded-full bg-gradient-to-br from-blue-400 to-blue-600 flex items-center justify-center text-white text-xs font-bold shadow-sm">
514
614
  {clientInitials}
515
615
  </div>
516
616
  ) : (
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">
617
+ <div className="flex-shrink-0 h-6 w-6 sm:h-7 sm:w-7 rounded-full bg-gradient-to-br from-slate-500 to-slate-700 flex items-center justify-center text-white text-xs font-bold shadow-sm">
518
618
  CW
519
619
  </div>
520
620
  )}
521
621
 
522
- {/* Message bubble */}
622
+ {/* Asymmetric bubbles:
623
+ - client on the RIGHT with soft info-50 background (warm, identifies the user)
624
+ - support on the LEFT with neutral elevation background
625
+ - text is left-aligned in BOTH bubbles for readability (per refonte brief) */}
523
626
  <div className={`group relative max-w-[88%] sm:max-w-[80%] md:max-w-[75%] ${isClient ? 'items-end' : 'items-start'}`}>
524
627
  <div
525
- className={`relative rounded-2xl px-4 py-2.5 ${
628
+ className={`relative rounded-2xl px-4 py-2.5 text-left ${
526
629
  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'
630
+ ? 'rounded-br-md bg-blue-50 text-slate-800 ring-1 ring-blue-100 dark:bg-blue-950/40 dark:text-slate-100 dark:ring-blue-900/40'
631
+ : 'rounded-bl-md bg-slate-100 text-slate-800 ring-1 ring-slate-200 dark:bg-slate-700/80 dark:text-slate-100 dark:ring-slate-600/40'
529
632
  }`}
530
633
  >
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'}`}>
634
+ {/* Author + time — always left-aligned for both sides */}
635
+ <div className="mb-1 flex items-center gap-2">
636
+ <span className={`text-xs font-semibold ${
637
+ isClient
638
+ ? 'text-blue-700 dark:text-blue-300'
639
+ : 'text-slate-600 dark:text-slate-300'
640
+ }`}>
534
641
  {authorName}
535
642
  </span>
536
- <span className={`text-xs font-mono ${isClient ? 'text-slate-500 dark:text-slate-400' : 'text-blue-200'}`}>
643
+ <span className="text-xs font-mono text-slate-500 dark:text-slate-400">
537
644
  {msg.createdAt ? formatMessageDate(msg.createdAt) : ''}
538
645
  <EditedBadge editedAt={(msg as unknown as { editedAt?: string }).editedAt} />
539
646
  </span>
@@ -544,11 +651,7 @@ export default async function TicketDetailPage({ params }: { params: Promise<{ i
544
651
  <DeletedMessage />
545
652
  ) : msg.bodyHtml ? (
546
653
  <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
- }`}
654
+ className="text-sm leading-relaxed [&_blockquote]:my-2 [&_blockquote]:border-l-2 [&_blockquote]:border-slate-300 [&_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 [&_a]:text-blue-600 dark:[&_a]:text-blue-400 [&_a]:underline"
552
655
  dangerouslySetInnerHTML={{ __html: msg.bodyHtml }}
553
656
  />
554
657
  ) : (
@@ -579,7 +682,7 @@ export default async function TicketDetailPage({ params }: { params: Promise<{ i
579
682
  href={file.url || '#'}
580
683
  target="_blank"
581
684
  rel="noopener noreferrer"
582
- className="block overflow-hidden rounded-xl border border-white/20 transition-opacity hover:opacity-90"
685
+ className="block overflow-hidden rounded-xl border border-slate-200 dark:border-slate-600 transition-opacity hover:opacity-90"
583
686
  >
584
687
  {/* eslint-disable-next-line @next/next/no-img-element */}
585
688
  <img
@@ -626,11 +729,7 @@ export default async function TicketDetailPage({ params }: { params: Promise<{ i
626
729
  href={file.url || '#'}
627
730
  target="_blank"
628
731
  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
- }`}
732
+ className="inline-flex items-center gap-1.5 rounded-lg bg-white/70 dark:bg-slate-800/70 px-2.5 py-1.5 text-xs font-medium text-slate-700 dark:text-slate-300 transition-colors hover:bg-white dark:hover:bg-slate-700"
634
733
  >
635
734
  <svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
636
735
  <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" />