@consilioweb/payload-support 0.10.1 → 0.12.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.
- package/dist/components/TicketConversation/locales/en.json +25 -1
- package/dist/components/TicketConversation/locales/fr.json +25 -1
- package/dist/index.cjs +799 -4
- package/dist/index.d.cts +15 -1
- package/dist/index.d.ts +15 -1
- package/dist/index.js +800 -6
- package/dist/styles/TicketDetail.module.scss +899 -353
- package/dist/views/TicketDetailView/client.cjs +504 -282
- package/dist/views/TicketDetailView/client.js +505 -283
- package/package.json +1 -1
- package/src/collections/ClientSummaries.ts +16 -0
- package/src/collections/TicketCollaborators.ts +93 -0
- package/src/collections/TicketFeedback.ts +116 -0
- package/src/collections/TicketMessages.ts +9 -0
- package/src/collections/Tickets.ts +31 -1
- package/src/collections/index.ts +2 -0
- package/src/components/TicketConversation/locales/en.json +25 -1
- package/src/components/TicketConversation/locales/fr.json +25 -1
- package/src/endpoints/escalate.ts +44 -0
- package/src/endpoints/index.ts +15 -0
- package/src/endpoints/invite-collaborator.ts +215 -0
- package/src/endpoints/kb-search.ts +156 -0
- package/src/endpoints/ticket-feedback.ts +104 -0
- package/src/endpoints/transfer-ticket.ts +248 -0
- package/src/index.ts +1 -0
- package/src/plugin.ts +4 -0
- package/src/portal/auth/ChatWidget.tsx +11 -0
- package/src/portal/auth/dashboard/DashboardClient.tsx +85 -99
- package/src/portal/auth/dashboard/page.tsx +11 -2
- package/src/portal/auth/tickets/detail/TransferAndInviteActions.tsx +402 -0
- package/src/portal/auth/tickets/detail/page.tsx +122 -23
- package/src/portal/auth/tickets/new/KbDeflection.tsx +128 -0
- package/src/portal/auth/tickets/new/page.tsx +203 -100
- package/src/portal/locales/en.json +5 -0
- package/src/portal/locales/fr.json +5 -0
- package/src/styles/TicketDetail.module.scss +899 -353
- package/src/types.ts +19 -0
- package/src/utils/slugs.ts +2 -0
- package/src/views/TicketDetailView/client.tsx +404 -121
|
@@ -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é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è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-
|
|
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-
|
|
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
|
-
{/*
|
|
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-
|
|
528
|
-
: 'rounded-bl-md bg-
|
|
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=
|
|
533
|
-
<span className={`text-xs font-semibold ${
|
|
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=
|
|
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=
|
|
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-
|
|
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=
|
|
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" />
|