@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,74 @@
1
+ 'use client'
2
+
3
+ import React, { useState } from 'react'
4
+ import { useRouter } from 'next/navigation'
5
+
6
+ export function CloseTicketButton({ ticketId }: { ticketId: number | string }) {
7
+ const router = useRouter()
8
+ const [loading, setLoading] = useState(false)
9
+ const [confirming, setConfirming] = useState(false)
10
+
11
+ const handleClose = async () => {
12
+ if (!confirming) {
13
+ setConfirming(true)
14
+ return
15
+ }
16
+
17
+ setLoading(true)
18
+ try {
19
+ const res = await fetch(`/api/tickets/${ticketId}`, {
20
+ method: 'PATCH',
21
+ headers: { 'Content-Type': 'application/json' },
22
+ credentials: 'include',
23
+ body: JSON.stringify({ status: 'resolved' }),
24
+ })
25
+
26
+ if (res.ok) {
27
+ router.refresh()
28
+ }
29
+ } catch (err) {
30
+ console.warn('[CloseTicketButton] Failed to close ticket:', err)
31
+ } finally {
32
+ setLoading(false)
33
+ setConfirming(false)
34
+ }
35
+ }
36
+
37
+ return (
38
+ <button
39
+ onClick={handleClose}
40
+ disabled={loading}
41
+ className={`inline-flex items-center gap-1.5 rounded-xl border px-3.5 py-2 text-sm font-semibold transition-all disabled:opacity-50 ${
42
+ confirming
43
+ ? 'border-red-200 dark:border-red-800/50 bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/30'
44
+ : 'border-emerald-200 dark:border-emerald-800/50 bg-emerald-50 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-400 hover:bg-emerald-100 dark:hover:bg-emerald-900/30'
45
+ }`}
46
+ >
47
+ {loading ? (
48
+ <>
49
+ <svg className="h-3.5 w-3.5 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
50
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
51
+ <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" />
52
+ </svg>
53
+ Fermeture...
54
+ </>
55
+ ) : confirming ? (
56
+ <>
57
+ <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">
58
+ <circle cx="12" cy="12" r="10" />
59
+ <line x1="15" y1="9" x2="9" y2="15" />
60
+ <line x1="9" y1="9" x2="15" y2="15" />
61
+ </svg>
62
+ Confirmer ?
63
+ </>
64
+ ) : (
65
+ <>
66
+ <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">
67
+ <polyline points="20,6 9,17 4,12" />
68
+ </svg>
69
+ R&eacute;solu
70
+ </>
71
+ )}
72
+ </button>
73
+ )
74
+ }
@@ -0,0 +1,46 @@
1
+ 'use client'
2
+
3
+ import React, { useState } from 'react'
4
+
5
+ interface CollapsibleMessagesProps {
6
+ children: React.ReactNode[]
7
+ }
8
+
9
+ const VISIBLE_COUNT = 3
10
+
11
+ export const CollapsibleMessages: React.FC<CollapsibleMessagesProps> = ({ children }) => {
12
+ const [collapsed, setCollapsed] = useState(true)
13
+
14
+ if (!children || children.length <= VISIBLE_COUNT) {
15
+ return <>{children}</>
16
+ }
17
+
18
+ const hiddenCount = children.length - VISIBLE_COUNT
19
+ const lastMessages = children.slice(-VISIBLE_COUNT)
20
+
21
+ if (collapsed) {
22
+ return (
23
+ <>
24
+ <button
25
+ onClick={() => setCollapsed(false)}
26
+ className="no-print w-full rounded-2xl border-2 border-dashed border-gray-300 px-4 py-3 text-center text-sm font-semibold text-gray-400 transition-colors hover:border-gray-400 hover:text-gray-600"
27
+ >
28
+ Voir les {hiddenCount} message{hiddenCount > 1 ? 's' : ''} précédent{hiddenCount > 1 ? 's' : ''}
29
+ </button>
30
+ {lastMessages}
31
+ </>
32
+ )
33
+ }
34
+
35
+ return (
36
+ <>
37
+ <button
38
+ onClick={() => setCollapsed(true)}
39
+ className="no-print w-full rounded-2xl border-2 border-dashed border-gray-300 px-4 py-3 text-center text-sm font-semibold text-gray-400 transition-colors hover:border-gray-400 hover:text-gray-600"
40
+ >
41
+ Masquer les anciens messages
42
+ </button>
43
+ {children}
44
+ </>
45
+ )
46
+ }
@@ -0,0 +1,50 @@
1
+ 'use client'
2
+
3
+ import React, { useState } from 'react'
4
+ import { useRouter } from 'next/navigation'
5
+
6
+ export function MarkSolutionButton({
7
+ messageId,
8
+ isSolution,
9
+ }: {
10
+ messageId: number | string
11
+ isSolution: boolean
12
+ }) {
13
+ const router = useRouter()
14
+ const [loading, setLoading] = useState(false)
15
+
16
+ const handleToggle = async () => {
17
+ setLoading(true)
18
+ try {
19
+ await fetch(`/api/ticket-messages/${messageId}`, {
20
+ method: 'PATCH',
21
+ headers: { 'Content-Type': 'application/json' },
22
+ credentials: 'include',
23
+ body: JSON.stringify({ isSolution: !isSolution }),
24
+ })
25
+ router.refresh()
26
+ } catch (err) {
27
+ console.warn('[MarkSolutionButton] Failed to toggle solution:', err)
28
+ } finally {
29
+ setLoading(false)
30
+ }
31
+ }
32
+
33
+ return (
34
+ <button
35
+ onClick={handleToggle}
36
+ disabled={loading}
37
+ className={`mt-2 flex items-center gap-1 rounded-lg px-2 py-1 text-xs font-bold transition-all ${
38
+ isSolution
39
+ ? 'border-2 border-green-500 bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-400'
40
+ : 'border-2 border-gray-200 text-gray-400 hover:border-green-500 hover:text-green-600 dark:border-gray-600 dark:hover:border-green-500'
41
+ } disabled:opacity-50`}
42
+ title={isSolution ? 'Retirer le marquage solution' : 'Marquer comme solution'}
43
+ >
44
+ <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.5 w-3.5">
45
+ <polyline points="20,6 9,17 4,12" />
46
+ </svg>
47
+ {isSolution ? 'Solution' : 'Marquer comme solution'}
48
+ </button>
49
+ )
50
+ }
@@ -0,0 +1,158 @@
1
+ 'use client'
2
+
3
+ import { useState } from 'react'
4
+ import { useRouter } from 'next/navigation'
5
+
6
+ const EDIT_WINDOW_MS = 5 * 60 * 1000 // 5 minutes
7
+
8
+ export function MessageActions({
9
+ messageId,
10
+ body,
11
+ createdAt,
12
+ }: {
13
+ messageId: number
14
+ body: string
15
+ createdAt: string
16
+ }) {
17
+ const router = useRouter()
18
+ const [editing, setEditing] = useState(false)
19
+ const [editText, setEditText] = useState(body)
20
+ const [saving, setSaving] = useState(false)
21
+ const [confirmDelete, setConfirmDelete] = useState(false)
22
+
23
+ const elapsed = Date.now() - new Date(createdAt).getTime()
24
+ const canEdit = elapsed < EDIT_WINDOW_MS
25
+
26
+ if (!canEdit) return null
27
+
28
+ const remainingMin = Math.max(0, Math.ceil((EDIT_WINDOW_MS - elapsed) / 60000))
29
+
30
+ const handleSave = async () => {
31
+ if (!editText.trim() || editText === body) {
32
+ setEditing(false)
33
+ return
34
+ }
35
+ setSaving(true)
36
+ try {
37
+ const res = await fetch(`/api/ticket-messages/${messageId}`, {
38
+ method: 'PATCH',
39
+ headers: { 'Content-Type': 'application/json' },
40
+ credentials: 'include',
41
+ body: JSON.stringify({
42
+ body: editText.trim(),
43
+ editedAt: new Date().toISOString(),
44
+ }),
45
+ })
46
+ if (res.ok) {
47
+ setEditing(false)
48
+ router.refresh()
49
+ }
50
+ } catch { /* ignore */ }
51
+ setSaving(false)
52
+ }
53
+
54
+ const handleDelete = async () => {
55
+ setSaving(true)
56
+ try {
57
+ const res = await fetch(`/api/ticket-messages/${messageId}`, {
58
+ method: 'PATCH',
59
+ headers: { 'Content-Type': 'application/json' },
60
+ credentials: 'include',
61
+ body: JSON.stringify({
62
+ body: '[Message supprimé]',
63
+ bodyHtml: '',
64
+ deletedAt: new Date().toISOString(),
65
+ }),
66
+ })
67
+ if (res.ok) {
68
+ setConfirmDelete(false)
69
+ router.refresh()
70
+ }
71
+ } catch { /* ignore */ }
72
+ setSaving(false)
73
+ }
74
+
75
+ if (editing) {
76
+ return (
77
+ <div className="mt-2 space-y-2">
78
+ <textarea
79
+ value={editText}
80
+ onChange={(e) => setEditText(e.target.value)}
81
+ rows={3}
82
+ className="w-full rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-900 px-3 py-2 text-sm text-slate-900 dark:text-white outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20"
83
+ />
84
+ <div className="flex gap-2">
85
+ <button
86
+ onClick={handleSave}
87
+ disabled={saving}
88
+ className="rounded-md bg-blue-600 px-3 py-1 text-xs font-semibold text-white hover:bg-blue-700 disabled:opacity-50"
89
+ >
90
+ {saving ? 'Enregistrement...' : 'Enregistrer'}
91
+ </button>
92
+ <button
93
+ onClick={() => { setEditing(false); setEditText(body) }}
94
+ className="rounded-md border border-slate-300 dark:border-slate-600 px-3 py-1 text-xs font-semibold text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800"
95
+ >
96
+ Annuler
97
+ </button>
98
+ </div>
99
+ </div>
100
+ )
101
+ }
102
+
103
+ if (confirmDelete) {
104
+ return (
105
+ <div className="mt-2 flex items-center gap-2">
106
+ <span className="text-xs text-red-500">Supprimer ce message ?</span>
107
+ <button
108
+ onClick={handleDelete}
109
+ disabled={saving}
110
+ className="rounded-md bg-red-600 px-2.5 py-1 text-xs font-semibold text-white hover:bg-red-700 disabled:opacity-50"
111
+ >
112
+ {saving ? '...' : 'Confirmer'}
113
+ </button>
114
+ <button
115
+ onClick={() => setConfirmDelete(false)}
116
+ className="rounded-md border border-slate-300 dark:border-slate-600 px-2.5 py-1 text-xs text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800"
117
+ >
118
+ Annuler
119
+ </button>
120
+ </div>
121
+ )
122
+ }
123
+
124
+ return (
125
+ <div className="mt-1.5 flex items-center gap-3">
126
+ <button
127
+ onClick={() => setEditing(true)}
128
+ className="text-[11px] font-medium text-slate-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
129
+ >
130
+ Modifier
131
+ </button>
132
+ <button
133
+ onClick={() => setConfirmDelete(true)}
134
+ className="text-[11px] font-medium text-slate-400 hover:text-red-500 transition-colors"
135
+ >
136
+ Supprimer
137
+ </button>
138
+ <span className="text-[10px] text-slate-300 dark:text-slate-600">{remainingMin}min restantes</span>
139
+ </div>
140
+ )
141
+ }
142
+
143
+ export function EditedBadge({ editedAt }: { editedAt?: string | null }) {
144
+ if (!editedAt) return null
145
+ return (
146
+ <span className="ml-2 text-[10px] text-slate-400 dark:text-slate-500 italic">
147
+ (modifié)
148
+ </span>
149
+ )
150
+ }
151
+
152
+ export function DeletedMessage() {
153
+ return (
154
+ <div className="italic text-sm text-slate-400 dark:text-slate-500">
155
+ Ce message a été supprimé.
156
+ </div>
157
+ )
158
+ }
@@ -0,0 +1,16 @@
1
+ 'use client'
2
+
3
+ export function PrintButton() {
4
+ return (
5
+ <button
6
+ onClick={() => window.print()}
7
+ className="no-print rounded-lg border border-slate-300 bg-white px-3 py-2 text-xs font-medium text-slate-600 hover:bg-slate-50 transition-colors"
8
+ title="Exporter en PDF"
9
+ >
10
+ <svg className="h-4 w-4 inline mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
11
+ <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" />
12
+ </svg>
13
+ PDF
14
+ </button>
15
+ )
16
+ }
@@ -0,0 +1,34 @@
1
+ 'use client'
2
+
3
+ const TZ = 'Europe/Paris'
4
+
5
+ export function ReadReceipt({
6
+ lastAdminReadAt,
7
+ messageCreatedAt,
8
+ }: {
9
+ lastAdminReadAt?: string | null
10
+ messageCreatedAt: string
11
+ }) {
12
+ if (!lastAdminReadAt) return null
13
+
14
+ const readTime = new Date(lastAdminReadAt).getTime()
15
+ const msgTime = new Date(messageCreatedAt).getTime()
16
+
17
+ // Only show "Lu" if the admin read after this message was created
18
+ if (readTime < msgTime) return null
19
+
20
+ const readDate = new Date(lastAdminReadAt)
21
+ const formatted = readDate.toLocaleTimeString('fr-FR', {
22
+ hour: '2-digit',
23
+ minute: '2-digit',
24
+ timeZone: TZ,
25
+ })
26
+
27
+ return (
28
+ <div className="mt-1 text-right">
29
+ <span className="text-[10px] text-blue-400 dark:text-blue-500 font-medium">
30
+ Lu {formatted}
31
+ </span>
32
+ </div>
33
+ )
34
+ }
@@ -0,0 +1,74 @@
1
+ 'use client'
2
+
3
+ import React, { useState } from 'react'
4
+ import { useRouter } from 'next/navigation'
5
+
6
+ export function ReopenTicketButton({ ticketId }: { ticketId: number | string }) {
7
+ const router = useRouter()
8
+ const [loading, setLoading] = useState(false)
9
+ const [confirming, setConfirming] = useState(false)
10
+
11
+ const handleReopen = async () => {
12
+ if (!confirming) {
13
+ setConfirming(true)
14
+ return
15
+ }
16
+
17
+ setLoading(true)
18
+ try {
19
+ const res = await fetch(`/api/tickets/${ticketId}`, {
20
+ method: 'PATCH',
21
+ headers: { 'Content-Type': 'application/json' },
22
+ credentials: 'include',
23
+ body: JSON.stringify({ status: 'open' }),
24
+ })
25
+
26
+ if (res.ok) {
27
+ router.refresh()
28
+ }
29
+ } catch (err) {
30
+ console.warn('[ReopenTicketButton] Failed to reopen ticket:', err)
31
+ } finally {
32
+ setLoading(false)
33
+ setConfirming(false)
34
+ }
35
+ }
36
+
37
+ return (
38
+ <button
39
+ onClick={handleReopen}
40
+ disabled={loading}
41
+ className={`inline-flex items-center gap-2 rounded-xl border px-4 py-2.5 text-sm font-semibold transition-all disabled:opacity-50 ${
42
+ confirming
43
+ ? 'border-emerald-200 dark:border-emerald-800/50 bg-emerald-50 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-400 hover:bg-emerald-100 dark:hover:bg-emerald-900/30'
44
+ : 'border-blue-200 dark:border-blue-800/50 bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-400 hover:bg-blue-100 dark:hover:bg-blue-900/30'
45
+ }`}
46
+ >
47
+ {loading ? (
48
+ <>
49
+ <svg className="h-4 w-4 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
50
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
51
+ <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" />
52
+ </svg>
53
+ R&eacute;ouverture...
54
+ </>
55
+ ) : confirming ? (
56
+ <>
57
+ <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">
58
+ <polyline points="23 4 23 10 17 10" />
59
+ <path d="M20.49 15a9 9 0 11-2.12-9.36L23 10" />
60
+ </svg>
61
+ Confirmer ?
62
+ </>
63
+ ) : (
64
+ <>
65
+ <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">
66
+ <polyline points="23 4 23 10 17 10" />
67
+ <path d="M20.49 15a9 9 0 11-2.12-9.36L23 10" />
68
+ </svg>
69
+ Rouvrir le ticket
70
+ </>
71
+ )}
72
+ </button>
73
+ )
74
+ }
@@ -0,0 +1,156 @@
1
+ 'use client'
2
+
3
+ import React, { useState } from 'react'
4
+
5
+ export function SatisfactionForm({ ticketId }: { ticketId: number | string }) {
6
+ const [rating, setRating] = useState(0)
7
+ const [hoveredRating, setHoveredRating] = useState(0)
8
+ const [comment, setComment] = useState('')
9
+ const [loading, setLoading] = useState(false)
10
+ const [submitted, setSubmitted] = useState(false)
11
+ const [error, setError] = useState('')
12
+
13
+ const handleSubmit = async (e: React.FormEvent) => {
14
+ e.preventDefault()
15
+ if (rating === 0) {
16
+ setError('Veuillez sélectionner une note.')
17
+ return
18
+ }
19
+
20
+ setLoading(true)
21
+ setError('')
22
+
23
+ try {
24
+ const res = await fetch('/api/support/satisfaction', {
25
+ method: 'POST',
26
+ headers: { 'Content-Type': 'application/json' },
27
+ credentials: 'include',
28
+ body: JSON.stringify({
29
+ ticketId,
30
+ rating,
31
+ ...(comment.trim() ? { comment: comment.trim() } : {}),
32
+ }),
33
+ })
34
+
35
+ if (res.ok) {
36
+ setSubmitted(true)
37
+ } else {
38
+ const data = await res.json()
39
+ setError(data.error || 'Erreur lors de l\'envoi.')
40
+ }
41
+ } catch {
42
+ setError('Erreur de connexion.')
43
+ } finally {
44
+ setLoading(false)
45
+ }
46
+ }
47
+
48
+ if (submitted) {
49
+ return (
50
+ <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">
51
+ <div className="mx-auto mb-3 flex h-10 w-10 items-center justify-center rounded-full bg-emerald-500 shadow-lg shadow-emerald-500/20">
52
+ <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">
53
+ <polyline points="20,6 9,17 4,12" />
54
+ </svg>
55
+ </div>
56
+ <p className="text-base font-bold text-emerald-900 dark:text-emerald-200">Merci pour votre avis !</p>
57
+ <p className="mt-1 text-sm text-emerald-600 dark:text-emerald-400">Votre retour nous aide &agrave; nous am&eacute;liorer.</p>
58
+ </div>
59
+ )
60
+ }
61
+
62
+ const stars = [1, 2, 3, 4, 5]
63
+ const labels = ['Très insatisfait', 'Insatisfait', 'Correct', 'Satisfait', 'Très satisfait']
64
+
65
+ return (
66
+ <form
67
+ onSubmit={handleSubmit}
68
+ className="rounded-2xl border border-amber-200/80 dark:border-amber-800/50 bg-gradient-to-br from-amber-50/80 to-orange-50/40 dark:from-amber-900/10 dark:to-orange-900/10 p-5 sm:p-6 shadow-sm"
69
+ >
70
+ <div className="mb-1 flex items-center gap-2">
71
+ <div className="flex h-8 w-8 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-900/30">
72
+ <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-amber-600 dark:text-amber-400">
73
+ <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
74
+ </svg>
75
+ </div>
76
+ <h3 className="text-base font-bold text-slate-900 dark:text-white">Comment s&apos;est pass&eacute; le support ?</h3>
77
+ </div>
78
+ <p className="mb-5 ml-10 text-sm text-slate-500 dark:text-slate-400">Votre avis nous aide &agrave; am&eacute;liorer notre service.</p>
79
+
80
+ {error && (
81
+ <div className="mb-4 flex items-center gap-2 rounded-xl border border-red-200 dark:border-red-800/50 bg-red-50 dark:bg-red-900/20 px-4 py-3 text-sm text-red-700 dark:text-red-400">
82
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4 flex-shrink-0">
83
+ <circle cx="12" cy="12" r="10" />
84
+ <line x1="15" y1="9" x2="9" y2="15" />
85
+ <line x1="9" y1="9" x2="15" y2="15" />
86
+ </svg>
87
+ {error}
88
+ </div>
89
+ )}
90
+
91
+ {/* Star rating */}
92
+ <div className="mb-1 flex items-center gap-0.5">
93
+ {stars.map((star) => (
94
+ <button
95
+ key={star}
96
+ type="button"
97
+ onClick={() => setRating(star)}
98
+ onMouseEnter={() => setHoveredRating(star)}
99
+ onMouseLeave={() => setHoveredRating(0)}
100
+ className="rounded-lg p-1 transition-all hover:scale-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-400"
101
+ >
102
+ <svg
103
+ className={`h-8 w-8 transition-colors ${
104
+ star <= (hoveredRating || rating)
105
+ ? 'fill-amber-400 text-amber-400'
106
+ : 'fill-slate-200 dark:fill-slate-600 text-slate-300 dark:text-slate-500'
107
+ }`}
108
+ viewBox="0 0 24 24"
109
+ stroke="currentColor"
110
+ strokeWidth={1.5}
111
+ >
112
+ <path
113
+ strokeLinecap="round"
114
+ strokeLinejoin="round"
115
+ d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.563.563 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z"
116
+ />
117
+ </svg>
118
+ </button>
119
+ ))}
120
+ </div>
121
+ {(hoveredRating || rating) > 0 && (
122
+ <p className="mb-4 text-xs font-medium text-slate-500 dark:text-slate-400">
123
+ {labels[(hoveredRating || rating) - 1]}
124
+ </p>
125
+ )}
126
+ {(hoveredRating || rating) === 0 && <div className="mb-4" />}
127
+
128
+ {/* Comment */}
129
+ <textarea
130
+ rows={3}
131
+ value={comment}
132
+ onChange={(e) => setComment(e.target.value)}
133
+ placeholder="Un commentaire ? (optionnel)"
134
+ className="mb-4 w-full resize-y rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 px-4 py-3 text-sm text-slate-900 dark:text-slate-200 placeholder-slate-400 dark:placeholder-slate-500 outline-none transition-all focus:border-blue-400 dark:focus:border-blue-500 focus:ring-2 focus:ring-blue-400/20 dark:focus:ring-blue-500/20"
135
+ />
136
+
137
+ <button
138
+ type="submit"
139
+ disabled={loading || rating === 0}
140
+ className="inline-flex items-center gap-2 rounded-xl bg-blue-600 px-5 py-2.5 text-sm font-semibold text-white shadow-sm transition-all hover:bg-blue-700 hover:shadow-md disabled:opacity-40 disabled:cursor-not-allowed"
141
+ >
142
+ {loading ? (
143
+ <>
144
+ <svg className="h-4 w-4 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
145
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
146
+ <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" />
147
+ </svg>
148
+ Envoi...
149
+ </>
150
+ ) : (
151
+ 'Envoyer mon avis'
152
+ )}
153
+ </button>
154
+ </form>
155
+ )
156
+ }
@@ -0,0 +1,57 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useRef, useState } from 'react'
4
+ import { useRouter } from 'next/navigation'
5
+
6
+ const POLL_INTERVAL = 30_000 // 30 seconds
7
+
8
+ /**
9
+ * Polls for new messages on the current ticket.
10
+ * Uses Payload REST API to check message count, then triggers a router.refresh()
11
+ * if new messages are detected. Stops polling on session expiry (401/403).
12
+ */
13
+ export function TicketPolling({
14
+ ticketId,
15
+ messageCount,
16
+ }: {
17
+ ticketId: number | string
18
+ messageCount: number
19
+ }) {
20
+ const router = useRouter()
21
+ const lastCount = useRef(messageCount)
22
+ const [sessionExpired, setSessionExpired] = useState(false)
23
+
24
+ useEffect(() => {
25
+ lastCount.current = messageCount
26
+ }, [messageCount])
27
+
28
+ useEffect(() => {
29
+ if (sessionExpired) return
30
+
31
+ const interval = setInterval(async () => {
32
+ try {
33
+ const res = await fetch(
34
+ `/api/ticket-messages?where[ticket][equals]=${ticketId}&limit=0&depth=0`,
35
+ { credentials: 'include' },
36
+ )
37
+ if (res.status === 401 || res.status === 403) {
38
+ setSessionExpired(true)
39
+ return
40
+ }
41
+ if (!res.ok) return
42
+
43
+ const data = await res.json()
44
+ if (data.totalDocs > lastCount.current) {
45
+ lastCount.current = data.totalDocs
46
+ router.refresh()
47
+ }
48
+ } catch (err) {
49
+ console.warn('[TicketPolling] Poll failed:', err)
50
+ }
51
+ }, POLL_INTERVAL)
52
+
53
+ return () => clearInterval(interval)
54
+ }, [ticketId, router, sessionExpired])
55
+
56
+ return null
57
+ }