@consilioweb/payload-support 0.16.0 → 1.1.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 (199) hide show
  1. package/README.md +357 -435
  2. package/dist/components/TicketConversation/RewriteDropdown.cjs +98 -0
  3. package/dist/components/TicketConversation/RewriteDropdown.js +93 -0
  4. package/dist/components/TicketConversation/SkeletonText.cjs +21 -0
  5. package/dist/components/TicketConversation/SkeletonText.js +20 -0
  6. package/dist/components/TicketConversation/constants.cjs +27 -0
  7. package/dist/components/TicketConversation/constants.js +26 -1
  8. package/dist/components/TicketConversation/hooks/useDocumentIdFromUrl.cjs +20 -0
  9. package/dist/components/TicketConversation/hooks/useDocumentIdFromUrl.js +19 -0
  10. package/dist/components/TicketConversation/index.cjs +10 -147
  11. package/dist/components/TicketConversation/index.js +4 -141
  12. package/dist/index.cjs +1763 -677
  13. package/dist/index.d.cts +4 -0
  14. package/dist/index.d.ts +4 -0
  15. package/dist/index.js +1760 -677
  16. package/dist/views/BillingView/client.cjs +40 -54
  17. package/dist/views/BillingView/client.js +40 -54
  18. package/dist/views/ChatView/client.cjs +4 -3
  19. package/dist/views/ChatView/client.js +4 -3
  20. package/dist/views/CrmView/client.cjs +7 -7
  21. package/dist/views/CrmView/client.js +7 -7
  22. package/dist/views/EmailTrackingView/client.cjs +6 -5
  23. package/dist/views/EmailTrackingView/client.js +6 -5
  24. package/dist/views/ImportConversationView/client.cjs +4 -4
  25. package/dist/views/ImportConversationView/client.js +4 -4
  26. package/dist/views/LogsView/client.cjs +3 -2
  27. package/dist/views/LogsView/client.js +3 -2
  28. package/dist/views/NewTicketView/client.cjs +1 -1
  29. package/dist/views/NewTicketView/client.js +1 -1
  30. package/dist/views/PendingEmailsView/client.cjs +7 -6
  31. package/dist/views/PendingEmailsView/client.js +7 -6
  32. package/dist/views/SupportDashboardView/client.cjs +19 -22
  33. package/dist/views/SupportDashboardView/client.js +19 -22
  34. package/dist/views/TicketDetailView/NextActionItem.cjs +40 -0
  35. package/dist/views/TicketDetailView/NextActionItem.js +34 -0
  36. package/dist/views/TicketDetailView/RewriteDropdown.cjs +80 -0
  37. package/dist/views/TicketDetailView/RewriteDropdown.js +78 -0
  38. package/dist/views/TicketDetailView/client.cjs +59 -190
  39. package/dist/views/TicketDetailView/client.js +52 -183
  40. package/dist/views/TicketDetailView/constants.cjs +20 -0
  41. package/dist/views/TicketDetailView/constants.js +16 -0
  42. package/dist/views/TicketDetailView/helpers.cjs +23 -0
  43. package/dist/views/TicketDetailView/helpers.js +20 -0
  44. package/dist/views/TicketDetailView/types.cjs +2 -0
  45. package/dist/views/TicketDetailView/types.js +1 -0
  46. package/dist/views/TicketInboxView/client.cjs +14 -10
  47. package/dist/views/TicketInboxView/client.js +14 -10
  48. package/dist/views/TicketingSettingsView/client.cjs +101 -96
  49. package/dist/views/TicketingSettingsView/client.js +92 -88
  50. package/dist/views/TimeDashboardView/client.cjs +2 -2
  51. package/dist/views/TimeDashboardView/client.js +2 -2
  52. package/dist/views/shared/StatusPill.cjs +23 -12
  53. package/dist/views/shared/StatusPill.js +23 -12
  54. package/dist/views/shared/dateLocale.cjs +5 -0
  55. package/dist/views/shared/dateLocale.js +3 -0
  56. package/package.json +28 -15
  57. package/src/__tests__/generateTrackingToken.test.ts +53 -0
  58. package/src/__tests__/integration/ai-agent.test.ts +46 -0
  59. package/src/__tests__/integration/auth.test.ts +43 -0
  60. package/src/__tests__/integration/automation-rules.test.ts +68 -0
  61. package/src/__tests__/integration/buildTestPayload.ts +52 -0
  62. package/src/__tests__/integration/channels.test.ts +52 -0
  63. package/src/__tests__/integration/core-behaviors.test.ts +58 -0
  64. package/src/__tests__/integration/dashboard-volume.test.ts +31 -0
  65. package/src/__tests__/integration/digest.test.ts +48 -0
  66. package/src/__tests__/integration/invoice-pdf.test.ts +30 -0
  67. package/src/__tests__/integration/isolation.test.ts +81 -0
  68. package/src/__tests__/integration/mentions-invoice.test.ts +50 -0
  69. package/src/__tests__/integration/nps.test.ts +37 -0
  70. package/src/__tests__/integration/per-team-sla.test.ts +44 -0
  71. package/src/__tests__/integration/push.test.ts +75 -0
  72. package/src/__tests__/integration/sanitization.test.ts +35 -0
  73. package/src/__tests__/integration/sla-pause.test.ts +39 -0
  74. package/src/__tests__/integration/smoke.test.ts +26 -0
  75. package/src/__tests__/integration/snooze.test.ts +68 -0
  76. package/src/__tests__/integration/teams.test.ts +38 -0
  77. package/src/__tests__/rateLimiter.test.ts +89 -0
  78. package/src/__tests__/sanitizeHtml.test.ts +165 -0
  79. package/src/__tests__/sla.test.ts +258 -0
  80. package/src/collections/AutomationRules.ts +66 -0
  81. package/src/collections/NotificationQueue.ts +30 -0
  82. package/src/collections/PushSubscription.ts +28 -0
  83. package/src/collections/SatisfactionSurveys.ts +11 -2
  84. package/src/collections/SlaPolicies.ts +10 -0
  85. package/src/collections/SupportClients.ts +58 -1
  86. package/src/collections/SupportTeam.ts +27 -0
  87. package/src/collections/TicketActivityLog.ts +9 -4
  88. package/src/collections/TicketMessages.ts +140 -31
  89. package/src/collections/Tickets.ts +31 -73
  90. package/src/collections/index.ts +4 -0
  91. package/src/components/TicketConversation/RewriteDropdown.tsx +86 -0
  92. package/src/components/TicketConversation/SkeletonText.tsx +23 -0
  93. package/src/components/TicketConversation/constants.ts +29 -0
  94. package/src/components/TicketConversation/hooks/useDocumentIdFromUrl.ts +21 -0
  95. package/src/components/TicketConversation/index.tsx +4 -152
  96. package/src/endpoints/admin-chat-stream.ts +3 -4
  97. package/src/endpoints/admin-chat.ts +11 -20
  98. package/src/endpoints/admin-stats.ts +77 -35
  99. package/src/endpoints/ai-agent.ts +36 -0
  100. package/src/endpoints/apply-macro.ts +8 -14
  101. package/src/endpoints/auth-2fa.ts +12 -12
  102. package/src/endpoints/auto-close.ts +6 -10
  103. package/src/endpoints/billing.ts +17 -4
  104. package/src/endpoints/bulk-action.ts +7 -12
  105. package/src/endpoints/channels.ts +89 -0
  106. package/src/endpoints/chat-stream.ts +3 -4
  107. package/src/endpoints/chat.ts +7 -12
  108. package/src/endpoints/chatbot.ts +2 -2
  109. package/src/endpoints/client-intelligence.ts +4 -4
  110. package/src/endpoints/delete-account.ts +9 -16
  111. package/src/endpoints/email-stats.ts +2 -2
  112. package/src/endpoints/escalate.ts +2 -2
  113. package/src/endpoints/export-csv.ts +2 -2
  114. package/src/endpoints/export-data.ts +5 -8
  115. package/src/endpoints/import-conversation.ts +6 -10
  116. package/src/endpoints/index.ts +22 -1
  117. package/src/endpoints/invite-collaborator.ts +30 -15
  118. package/src/endpoints/invoice.ts +127 -0
  119. package/src/endpoints/kb-search.ts +2 -2
  120. package/src/endpoints/login.ts +12 -4
  121. package/src/endpoints/merge-clients.ts +14 -19
  122. package/src/endpoints/merge-tickets.ts +11 -18
  123. package/src/endpoints/oauth-google.ts +86 -39
  124. package/src/endpoints/pending-emails-process.ts +15 -25
  125. package/src/endpoints/process-digests.ts +93 -0
  126. package/src/endpoints/process-scheduled.ts +6 -10
  127. package/src/endpoints/process-snooze.ts +78 -0
  128. package/src/endpoints/purge-logs.ts +2 -2
  129. package/src/endpoints/push.ts +49 -0
  130. package/src/endpoints/resend-notification.ts +3 -4
  131. package/src/endpoints/round-robin-config.ts +3 -4
  132. package/src/endpoints/satisfaction.ts +21 -14
  133. package/src/endpoints/search.ts +5 -8
  134. package/src/endpoints/seed-kb.ts +3 -4
  135. package/src/endpoints/send-reminder.ts +6 -9
  136. package/src/endpoints/settings.ts +5 -2
  137. package/src/endpoints/signature.ts +3 -4
  138. package/src/endpoints/sla-check.ts +2 -2
  139. package/src/endpoints/split-ticket.ts +7 -12
  140. package/src/endpoints/statuses.ts +2 -2
  141. package/src/endpoints/ticket-feedback.ts +4 -6
  142. package/src/endpoints/ticket-synthesis.ts +2 -2
  143. package/src/endpoints/track-open.ts +16 -28
  144. package/src/endpoints/transfer-ticket.ts +21 -9
  145. package/src/endpoints/user-prefs.ts +3 -4
  146. package/src/hooks/automationRules.ts +84 -0
  147. package/src/hooks/checkSLA.ts +63 -17
  148. package/src/plugin.ts +8 -0
  149. package/src/portal/LiveChat.tsx +2 -63
  150. package/src/portal/auth/tickets/detail/CloseTicketButton.tsx +10 -0
  151. package/src/portal/auth/tickets/detail/MarkSolutionButton.tsx +14 -2
  152. package/src/portal/auth/tickets/detail/MessageActions.tsx +37 -18
  153. package/src/portal/auth/tickets/detail/ReopenTicketButton.tsx +10 -0
  154. package/src/portal/auth/tickets/detail/TransferAndInviteActions.tsx +3 -2
  155. package/src/portal/auth/tickets/detail/page.tsx +2 -2
  156. package/src/portal/helpers.ts +46 -0
  157. package/src/portal/login/page.tsx +3 -8
  158. package/src/portal/types.ts +15 -0
  159. package/src/types/lucide-react.d.ts +34 -0
  160. package/src/types/payload-config.d.ts +10 -0
  161. package/src/utils/adminNotification.ts +2 -2
  162. package/src/utils/aiAgent.ts +147 -0
  163. package/src/utils/auth.ts +5 -4
  164. package/src/utils/channels.ts +36 -0
  165. package/src/utils/db.ts +73 -0
  166. package/src/utils/emailTemplate.ts +8 -1
  167. package/src/utils/fireWebhooks.ts +4 -6
  168. package/src/utils/generateTicketSynthesis.ts +7 -12
  169. package/src/utils/invoicePdf.ts +88 -0
  170. package/src/utils/notificationQueue.ts +36 -0
  171. package/src/utils/push.ts +63 -0
  172. package/src/utils/readSettings.ts +22 -6
  173. package/src/utils/sanitizeHtml.ts +57 -0
  174. package/src/utils/slugs.ts +8 -0
  175. package/src/utils/teamAccess.ts +27 -0
  176. package/src/utils/ticketAccess.ts +53 -0
  177. package/src/utils/webhookDispatcher.ts +4 -6
  178. package/src/views/BillingView/client.tsx +49 -37
  179. package/src/views/ChatView/client.tsx +4 -3
  180. package/src/views/CrmView/client.tsx +7 -7
  181. package/src/views/EmailTrackingView/client.tsx +7 -6
  182. package/src/views/ImportConversationView/client.tsx +4 -4
  183. package/src/views/LogsView/client.tsx +3 -2
  184. package/src/views/NewTicketView/client.tsx +1 -1
  185. package/src/views/PendingEmailsView/client.tsx +7 -6
  186. package/src/views/SupportDashboardView/client.tsx +21 -21
  187. package/src/views/TicketDetailView/NextActionItem.tsx +30 -0
  188. package/src/views/TicketDetailView/RewriteDropdown.tsx +63 -0
  189. package/src/views/TicketDetailView/client.tsx +54 -179
  190. package/src/views/TicketDetailView/constants.ts +23 -0
  191. package/src/views/TicketDetailView/helpers.ts +17 -0
  192. package/src/views/TicketDetailView/types.ts +10 -0
  193. package/src/views/TicketInboxView/client.tsx +16 -10
  194. package/src/views/TicketingSettingsView/client.tsx +90 -87
  195. package/src/views/TimeDashboardView/client.tsx +2 -2
  196. package/src/views/shared/StatusPill.tsx +24 -12
  197. package/src/views/shared/dateLocale.ts +5 -0
  198. package/src/views/shared/locales/en.json +708 -0
  199. package/src/views/shared/locales/fr.json +708 -0
@@ -3,6 +3,7 @@ import type { CollectionSlugs } from '../utils/slugs'
3
3
  import { createAdminNotification } from '../utils/adminNotification'
4
4
  import { emailWrapper, emailButton, emailParagraph } from '../utils/emailTemplate'
5
5
  import { readSupportSettings } from '../utils/readSettings'
6
+ import { dbFindByID, dbFind, dbUpdate } from '../utils/db'
6
7
 
7
8
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
8
9
  type AnyDoc = any
@@ -145,8 +146,7 @@ async function resolveSlaPolicy(
145
146
  if (typeof policyRef === 'object' && (policyRef as AnyDoc).firstResponseTime) {
146
147
  return policyRef
147
148
  }
148
- return await payload.findByID({
149
- collection: slugs.slaPolicies as any,
149
+ return await dbFindByID(payload, slugs.slaPolicies, {
150
150
  id: policyId as number,
151
151
  depth: 0,
152
152
  overrideAccess: true,
@@ -157,11 +157,24 @@ async function resolveSlaPolicy(
157
157
  }
158
158
  }
159
159
 
160
- // Look for a default policy matching this ticket's priority
161
160
  const priority = field(ticket, 'priority') || 'normal'
161
+
162
+ // Per-team policy (team + priority) takes precedence over the global default.
163
+ const teamRef = field(ticket, 'team')
164
+ const teamId = typeof teamRef === 'object' && teamRef !== null ? (teamRef as AnyDoc).id : teamRef
165
+ if (teamId) {
166
+ try {
167
+ const teamPolicies = await dbFind(payload, slugs.slaPolicies, {
168
+ where: { and: [{ team: { equals: teamId } }, { priority: { equals: priority } }] },
169
+ limit: 1, depth: 0, overrideAccess: true,
170
+ })
171
+ if (teamPolicies.docs.length > 0) return teamPolicies.docs[0]
172
+ } catch { /* fall through to default */ }
173
+ }
174
+
175
+ // Look for a default policy matching this ticket's priority
162
176
  try {
163
- const defaults = await payload.find({
164
- collection: slugs.slaPolicies as any,
177
+ const defaults = await dbFind(payload, slugs.slaPolicies, {
165
178
  where: {
166
179
  and: [
167
180
  { isDefault: { equals: true } },
@@ -218,8 +231,7 @@ async function handleSlaBreach(
218
231
  if (typeof policyRef === 'object' && (policyRef as AnyDoc).escalateOnBreach !== undefined) {
219
232
  policy = policyRef
220
233
  } else {
221
- policy = await payload.findByID({
222
- collection: slugs.slaPolicies as any,
234
+ policy = await dbFindByID(payload, slugs.slaPolicies, {
223
235
  id: policyId as number,
224
236
  depth: 0,
225
237
  overrideAccess: true,
@@ -240,8 +252,7 @@ async function handleSlaBreach(
240
252
 
241
253
  const escalateUser = typeof policy.escalateTo === 'object' && (policy.escalateTo as AnyDoc).email
242
254
  ? policy.escalateTo
243
- : await payload.findByID({
244
- collection: slugs.users as any,
255
+ : await dbFindByID(payload, slugs.users, {
245
256
  id: escalateToId as number,
246
257
  depth: 0,
247
258
  overrideAccess: true,
@@ -312,8 +323,7 @@ export function createAssignSlaDeadlines(slugs: CollectionSlugs, notificationSlu
312
323
  updateData.slaPolicy = policy.id
313
324
  }
314
325
 
315
- await payload.update({
316
- collection: slugs.tickets as any,
326
+ await dbUpdate(payload, slugs.tickets, {
317
327
  id: doc.id,
318
328
  data: updateData,
319
329
  overrideAccess: true,
@@ -346,8 +356,7 @@ export function createCheckSlaOnResolve(slugs: CollectionSlugs, notificationSlug
346
356
  const deadline = new Date(doc.slaResolutionDue as string)
347
357
  const breached = now > deadline
348
358
 
349
- await payload.update({
350
- collection: slugs.tickets as any,
359
+ await dbUpdate(payload, slugs.tickets, {
351
360
  id: doc.id,
352
361
  data: { slaResolutionBreached: breached },
353
362
  overrideAccess: true,
@@ -364,6 +373,45 @@ export function createCheckSlaOnResolve(slugs: CollectionSlugs, notificationSlug
364
373
  }
365
374
  }
366
375
 
376
+ /**
377
+ * SLA pause-on-hold: time spent in `waiting_client` should not count against the
378
+ * resolution SLA (the agent is waiting on the customer). Entering hold stamps
379
+ * `slaPausedAt`; leaving it pushes `slaResolutionDue` forward by the paused span.
380
+ */
381
+ export function createPauseSlaOnHold(slugs: CollectionSlugs): CollectionAfterChangeHook {
382
+ return async ({ doc, previousDoc, operation, req }) => {
383
+ if (operation !== 'update' || !previousDoc) return doc
384
+ if (previousDoc.status === doc.status) return doc
385
+ if (!doc.slaResolutionDue) return doc
386
+
387
+ const wasWaiting = previousDoc.status === 'waiting_client'
388
+ const isWaiting = doc.status === 'waiting_client'
389
+
390
+ try {
391
+ if (!wasWaiting && isWaiting && !doc.slaPausedAt && doc.status !== 'resolved') {
392
+ // Entering hold — stop the resolution clock.
393
+ await req.payload.update({
394
+ collection: slugs.tickets,
395
+ id: doc.id,
396
+ data: { slaPausedAt: new Date().toISOString() },
397
+ overrideAccess: true,
398
+ })
399
+ } else if (wasWaiting && !isWaiting && doc.slaPausedAt) {
400
+ // Leaving hold — extend the deadline by however long we were paused.
401
+ const pausedMs = Date.now() - new Date(doc.slaPausedAt as string).getTime()
402
+ const data: Record<string, unknown> = { slaPausedAt: null }
403
+ if (pausedMs > 0) {
404
+ data.slaResolutionDue = new Date(new Date(doc.slaResolutionDue as string).getTime() + pausedMs).toISOString()
405
+ }
406
+ await req.payload.update({ collection: slugs.tickets, id: doc.id, data, overrideAccess: true })
407
+ }
408
+ } catch (err) {
409
+ console.error('[sla] Failed to pause/resume SLA on hold:', err)
410
+ }
411
+ return doc
412
+ }
413
+ }
414
+
367
415
  /**
368
416
  * Factory: Check SLA breach on first admin response.
369
417
  * Runs as afterChange on ticket-messages collection.
@@ -377,8 +425,7 @@ export function createCheckSlaOnReply(slugs: CollectionSlugs, notificationSlug =
377
425
  const { payload } = req
378
426
  const ticketId = typeof doc.ticket === 'object' ? (doc.ticket as AnyDoc).id : doc.ticket
379
427
 
380
- const ticket = await payload.findByID({
381
- collection: slugs.tickets as any,
428
+ const ticket = await dbFindByID(payload, slugs.tickets, {
382
429
  id: ticketId as number,
383
430
  depth: 1,
384
431
  overrideAccess: true,
@@ -394,8 +441,7 @@ export function createCheckSlaOnReply(slugs: CollectionSlugs, notificationSlug =
394
441
  const deadline = new Date(ticket.slaFirstResponseDue as string)
395
442
  const breached = now > deadline
396
443
 
397
- await payload.update({
398
- collection: slugs.tickets as any,
444
+ await dbUpdate(payload, slugs.tickets, {
399
445
  id: ticketId as number,
400
446
  data: { slaFirstResponseBreached: breached },
401
447
  overrideAccess: true,
package/src/plugin.ts CHANGED
@@ -23,6 +23,10 @@ import {
23
23
  createClientSummariesCollection,
24
24
  createTicketFeedbackCollection,
25
25
  createTicketCollaboratorsCollection,
26
+ createNotificationQueueCollection,
27
+ createAutomationRulesCollection,
28
+ createSupportTeamCollection,
29
+ createPushSubscriptionCollection,
26
30
  } from './collections'
27
31
 
28
32
  function viewConfig(component: string, path: string): AdminViewConfig {
@@ -90,6 +94,10 @@ export function supportPlugin(config?: SupportPluginConfig): Plugin {
90
94
  createKnowledgeBaseCollection(slugs),
91
95
  createTicketFeedbackCollection(slugs, { notificationSlug: config?.notificationSlug }),
92
96
  createTicketCollaboratorsCollection(slugs),
97
+ createNotificationQueueCollection(slugs),
98
+ createAutomationRulesCollection(slugs),
99
+ createSupportTeamCollection(slugs),
100
+ createPushSubscriptionCollection(slugs),
93
101
  ]
94
102
 
95
103
  // Auth logs (conditional)
@@ -2,69 +2,8 @@
2
2
 
3
3
  import React, { useState, useEffect, useRef, useCallback } from 'react'
4
4
  import { MessageCircle, Send, X, Minimize2, ArrowLeft } from 'lucide-react'
5
-
6
- // ─── Types ───────────────────────────────────────────────────
7
-
8
- interface ChatMessage {
9
- id: number | string
10
- body: string
11
- senderType: 'client' | 'agent' | 'system'
12
- createdAt: string
13
- }
14
-
15
- interface LiveChatSession {
16
- clientToken: string
17
- email: string
18
- name: string
19
- sessionId?: string
20
- }
21
-
22
- type Screen = 'closed' | 'identify' | 'chat' | 'rating'
23
-
24
- // ─── LocalStorage keys ──────────────────────────────────────
25
-
26
- const LS_KEY = 'support-livechat'
27
-
28
- function loadSession(): LiveChatSession | null {
29
- if (typeof window === 'undefined') return null
30
- try {
31
- const raw = localStorage.getItem(LS_KEY)
32
- if (!raw) return null
33
- const session = JSON.parse(raw) as LiveChatSession
34
-
35
- // Validate token expiry (24h) — extract timestamp from token
36
- if (session.clientToken) {
37
- const parts = session.clientToken.split('_')
38
- if (parts.length === 4) {
39
- const timestamp = parseInt(parts[2], 10)
40
- if (!isNaN(timestamp) && Date.now() - timestamp > 24 * 60 * 60 * 1000) {
41
- localStorage.removeItem(LS_KEY)
42
- return null
43
- }
44
- }
45
- }
46
-
47
- return session
48
- } catch {
49
- return null
50
- }
51
- }
52
-
53
- function saveSession(session: LiveChatSession): void {
54
- try {
55
- localStorage.setItem(LS_KEY, JSON.stringify(session))
56
- } catch {
57
- // localStorage full or disabled
58
- }
59
- }
60
-
61
- function clearSession(): void {
62
- try {
63
- localStorage.removeItem(LS_KEY)
64
- } catch {
65
- // Ignore
66
- }
67
- }
5
+ import type { ChatMessage, LiveChatSession, Screen } from './types'
6
+ import { loadSession, saveSession, clearSession } from './helpers'
68
7
 
69
8
  // ─── Component ───────────────────────────────────────────────
70
9
 
@@ -7,6 +7,7 @@ export function CloseTicketButton({ ticketId }: { ticketId: number | string }) {
7
7
  const router = useRouter()
8
8
  const [loading, setLoading] = useState(false)
9
9
  const [confirming, setConfirming] = useState(false)
10
+ const [error, setError] = useState('')
10
11
 
11
12
  const handleClose = async () => {
12
13
  if (!confirming) {
@@ -15,6 +16,7 @@ export function CloseTicketButton({ ticketId }: { ticketId: number | string }) {
15
16
  }
16
17
 
17
18
  setLoading(true)
19
+ setError('')
18
20
  try {
19
21
  const res = await fetch(`/api/tickets/${ticketId}`, {
20
22
  method: 'PATCH',
@@ -25,9 +27,12 @@ export function CloseTicketButton({ ticketId }: { ticketId: number | string }) {
25
27
 
26
28
  if (res.ok) {
27
29
  router.refresh()
30
+ } else {
31
+ setError('Impossible de fermer le ticket. Veuillez réessayer.')
28
32
  }
29
33
  } catch (err) {
30
34
  console.warn('[CloseTicketButton] Failed to close ticket:', err)
35
+ setError('Erreur de connexion. Veuillez réessayer.')
31
36
  } finally {
32
37
  setLoading(false)
33
38
  setConfirming(false)
@@ -35,6 +40,10 @@ export function CloseTicketButton({ ticketId }: { ticketId: number | string }) {
35
40
  }
36
41
 
37
42
  return (
43
+ <div className="flex flex-col items-end gap-1">
44
+ {error && (
45
+ <p className="text-xs font-medium text-red-600 dark:text-red-400">{error}</p>
46
+ )}
38
47
  <button
39
48
  onClick={handleClose}
40
49
  disabled={loading}
@@ -70,5 +79,6 @@ export function CloseTicketButton({ ticketId }: { ticketId: number | string }) {
70
79
  </>
71
80
  )}
72
81
  </button>
82
+ </div>
73
83
  )
74
84
  }
@@ -12,25 +12,33 @@ export function MarkSolutionButton({
12
12
  }) {
13
13
  const router = useRouter()
14
14
  const [loading, setLoading] = useState(false)
15
+ const [error, setError] = useState('')
15
16
 
16
17
  const handleToggle = async () => {
17
18
  setLoading(true)
19
+ setError('')
18
20
  try {
19
- await fetch(`/api/ticket-messages/${messageId}`, {
21
+ const res = await fetch(`/api/ticket-messages/${messageId}`, {
20
22
  method: 'PATCH',
21
23
  headers: { 'Content-Type': 'application/json' },
22
24
  credentials: 'include',
23
25
  body: JSON.stringify({ isSolution: !isSolution }),
24
26
  })
25
- router.refresh()
27
+ if (res.ok) {
28
+ router.refresh()
29
+ } else {
30
+ setError('Échec. Veuillez réessayer.')
31
+ }
26
32
  } catch (err) {
27
33
  console.warn('[MarkSolutionButton] Failed to toggle solution:', err)
34
+ setError('Erreur de connexion.')
28
35
  } finally {
29
36
  setLoading(false)
30
37
  }
31
38
  }
32
39
 
33
40
  return (
41
+ <div className="flex flex-col items-start gap-0.5">
34
42
  <button
35
43
  onClick={handleToggle}
36
44
  disabled={loading}
@@ -46,5 +54,9 @@ export function MarkSolutionButton({
46
54
  </svg>
47
55
  {isSolution ? 'Solution' : 'Marquer comme solution'}
48
56
  </button>
57
+ {error && (
58
+ <p className="text-[10px] font-medium text-red-500 dark:text-red-400">{error}</p>
59
+ )}
60
+ </div>
49
61
  )
50
62
  }
@@ -19,6 +19,7 @@ export function MessageActions({
19
19
  const [editText, setEditText] = useState(body)
20
20
  const [saving, setSaving] = useState(false)
21
21
  const [confirmDelete, setConfirmDelete] = useState(false)
22
+ const [error, setError] = useState('')
22
23
 
23
24
  const elapsed = Date.now() - new Date(createdAt).getTime()
24
25
  const canEdit = elapsed < EDIT_WINDOW_MS
@@ -33,6 +34,7 @@ export function MessageActions({
33
34
  return
34
35
  }
35
36
  setSaving(true)
37
+ setError('')
36
38
  try {
37
39
  const res = await fetch(`/api/ticket-messages/${messageId}`, {
38
40
  method: 'PATCH',
@@ -46,13 +48,18 @@ export function MessageActions({
46
48
  if (res.ok) {
47
49
  setEditing(false)
48
50
  router.refresh()
51
+ } else {
52
+ setError('Impossible de sauvegarder. Veuillez réessayer.')
49
53
  }
50
- } catch { /* ignore */ }
54
+ } catch {
55
+ setError('Erreur de connexion. Veuillez réessayer.')
56
+ }
51
57
  setSaving(false)
52
58
  }
53
59
 
54
60
  const handleDelete = async () => {
55
61
  setSaving(true)
62
+ setError('')
56
63
  try {
57
64
  const res = await fetch(`/api/ticket-messages/${messageId}`, {
58
65
  method: 'PATCH',
@@ -67,8 +74,12 @@ export function MessageActions({
67
74
  if (res.ok) {
68
75
  setConfirmDelete(false)
69
76
  router.refresh()
77
+ } else {
78
+ setError('Impossible de supprimer. Veuillez réessayer.')
70
79
  }
71
- } catch { /* ignore */ }
80
+ } catch {
81
+ setError('Erreur de connexion. Veuillez réessayer.')
82
+ }
72
83
  setSaving(false)
73
84
  }
74
85
 
@@ -90,33 +101,41 @@ export function MessageActions({
90
101
  {saving ? 'Enregistrement...' : 'Enregistrer'}
91
102
  </button>
92
103
  <button
93
- onClick={() => { setEditing(false); setEditText(body) }}
104
+ onClick={() => { setEditing(false); setEditText(body); setError('') }}
94
105
  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
106
  >
96
107
  Annuler
97
108
  </button>
98
109
  </div>
110
+ {error && (
111
+ <p className="text-xs font-medium text-red-600 dark:text-red-400">{error}</p>
112
+ )}
99
113
  </div>
100
114
  )
101
115
  }
102
116
 
103
117
  if (confirmDelete) {
104
118
  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>
119
+ <div className="mt-2 space-y-1.5">
120
+ <div className="flex items-center gap-2">
121
+ <span className="text-xs text-red-500">Supprimer ce message ?</span>
122
+ <button
123
+ onClick={handleDelete}
124
+ disabled={saving}
125
+ className="rounded-md bg-red-600 px-2.5 py-1 text-xs font-semibold text-white hover:bg-red-700 disabled:opacity-50"
126
+ >
127
+ {saving ? '...' : 'Confirmer'}
128
+ </button>
129
+ <button
130
+ onClick={() => { setConfirmDelete(false); setError('') }}
131
+ 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"
132
+ >
133
+ Annuler
134
+ </button>
135
+ </div>
136
+ {error && (
137
+ <p className="text-xs font-medium text-red-600 dark:text-red-400">{error}</p>
138
+ )}
120
139
  </div>
121
140
  )
122
141
  }
@@ -7,6 +7,7 @@ export function ReopenTicketButton({ ticketId }: { ticketId: number | string })
7
7
  const router = useRouter()
8
8
  const [loading, setLoading] = useState(false)
9
9
  const [confirming, setConfirming] = useState(false)
10
+ const [error, setError] = useState('')
10
11
 
11
12
  const handleReopen = async () => {
12
13
  if (!confirming) {
@@ -15,6 +16,7 @@ export function ReopenTicketButton({ ticketId }: { ticketId: number | string })
15
16
  }
16
17
 
17
18
  setLoading(true)
19
+ setError('')
18
20
  try {
19
21
  const res = await fetch(`/api/tickets/${ticketId}`, {
20
22
  method: 'PATCH',
@@ -25,9 +27,12 @@ export function ReopenTicketButton({ ticketId }: { ticketId: number | string })
25
27
 
26
28
  if (res.ok) {
27
29
  router.refresh()
30
+ } else {
31
+ setError('Impossible de rouvrir le ticket. Veuillez réessayer.')
28
32
  }
29
33
  } catch (err) {
30
34
  console.warn('[ReopenTicketButton] Failed to reopen ticket:', err)
35
+ setError('Erreur de connexion. Veuillez réessayer.')
31
36
  } finally {
32
37
  setLoading(false)
33
38
  setConfirming(false)
@@ -35,6 +40,10 @@ export function ReopenTicketButton({ ticketId }: { ticketId: number | string })
35
40
  }
36
41
 
37
42
  return (
43
+ <div className="flex flex-col items-end gap-1">
44
+ {error && (
45
+ <p className="text-xs font-medium text-red-600 dark:text-red-400">{error}</p>
46
+ )}
38
47
  <button
39
48
  onClick={handleReopen}
40
49
  disabled={loading}
@@ -70,5 +79,6 @@ export function ReopenTicketButton({ ticketId }: { ticketId: number | string })
70
79
  </>
71
80
  )}
72
81
  </button>
82
+ </div>
73
83
  )
74
84
  }
@@ -3,6 +3,7 @@
3
3
  import React, { useCallback, useEffect, useRef, useState } from 'react'
4
4
 
5
5
  type Toast = { type: 'success' | 'error'; message: string } | null
6
+ type ToastPayload = NonNullable<Toast>
6
7
  type Modal = 'transfer' | 'invite' | null
7
8
 
8
9
  const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
@@ -116,7 +117,7 @@ function TransferModal({
116
117
  }: {
117
118
  ticketId: number | string
118
119
  onClose: () => void
119
- onResult: (t: Toast) => void
120
+ onResult: (t: ToastPayload) => void
120
121
  }) {
121
122
  const [email, setEmail] = useState('')
122
123
  const [includeAttachments, setIncludeAttachments] = useState(true)
@@ -204,7 +205,7 @@ function InviteModal({
204
205
  }: {
205
206
  ticketId: number | string
206
207
  onClose: () => void
207
- onResult: (t: Toast) => void
208
+ onResult: (t: ToastPayload) => void
208
209
  }) {
209
210
  const [email, setEmail] = useState('')
210
211
  const [role, setRole] = useState<'viewer' | 'collaborator'>('viewer')
@@ -748,7 +748,7 @@ export default async function TicketDetailPage({ params }: { params: Promise<{ i
748
748
  {/* Edit/delete actions for client messages */}
749
749
  {isClient && msg.authorType === 'client' && !(msg as unknown as { deletedAt?: string }).deletedAt && (
750
750
  <MessageActions
751
- messageId={msg.id}
751
+ messageId={Number(msg.id)}
752
752
  body={msg.body}
753
753
  createdAt={msg.createdAt}
754
754
  />
@@ -786,7 +786,7 @@ export default async function TicketDetailPage({ params }: { params: Promise<{ i
786
786
 
787
787
  {/* Typing indicator inside conversation */}
788
788
  <div className="px-4 sm:px-6 pb-2">
789
- <TypingIndicator ticketId={ticket.id} />
789
+ <TypingIndicator ticketId={Number(ticket.id)} />
790
790
  </div>
791
791
  </div>
792
792
 
@@ -0,0 +1,46 @@
1
+ import type { LiveChatSession } from './types'
2
+
3
+ // ─── LocalStorage keys ──────────────────────────────────────
4
+
5
+ export const LS_KEY = 'support-livechat'
6
+
7
+ export function loadSession(): LiveChatSession | null {
8
+ if (typeof window === 'undefined') return null
9
+ try {
10
+ const raw = localStorage.getItem(LS_KEY)
11
+ if (!raw) return null
12
+ const session = JSON.parse(raw) as LiveChatSession
13
+
14
+ // Validate token expiry (24h) — extract timestamp from token
15
+ if (session.clientToken) {
16
+ const parts = session.clientToken.split('_')
17
+ if (parts.length === 4) {
18
+ const timestamp = parseInt(parts[2], 10)
19
+ if (!isNaN(timestamp) && Date.now() - timestamp > 24 * 60 * 60 * 1000) {
20
+ localStorage.removeItem(LS_KEY)
21
+ return null
22
+ }
23
+ }
24
+ }
25
+
26
+ return session
27
+ } catch {
28
+ return null
29
+ }
30
+ }
31
+
32
+ export function saveSession(session: LiveChatSession): void {
33
+ try {
34
+ localStorage.setItem(LS_KEY, JSON.stringify(session))
35
+ } catch {
36
+ // localStorage full or disabled
37
+ }
38
+ }
39
+
40
+ export function clearSession(): void {
41
+ try {
42
+ localStorage.removeItem(LS_KEY)
43
+ } catch {
44
+ // Ignore
45
+ }
46
+ }
@@ -55,14 +55,9 @@ function SupportLoginContent() {
55
55
  return
56
56
  }
57
57
 
58
- // Check if 2FA is enabled for this user
59
- if (data.user?.twoFactorEnabled) {
60
- // Logout immediately (we need 2FA first)
61
- await fetch('/api/support-clients/logout', {
62
- method: 'POST',
63
- credentials: 'include',
64
- })
65
-
58
+ // Server requires 2FA: credentials were valid but NO session was issued.
59
+ // Request the code and switch to the verification step.
60
+ if (data.requires2FA) {
66
61
  // Send 2FA code
67
62
  setSending2FA(true)
68
63
  const codeRes = await fetch('/api/support/2fa', {
@@ -0,0 +1,15 @@
1
+ export interface ChatMessage {
2
+ id: number | string
3
+ body: string
4
+ senderType: 'client' | 'agent' | 'system'
5
+ createdAt: string
6
+ }
7
+
8
+ export interface LiveChatSession {
9
+ clientToken: string
10
+ email: string
11
+ name: string
12
+ sessionId?: string
13
+ }
14
+
15
+ export type Screen = 'closed' | 'identify' | 'chat' | 'rating'
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Ambient stub for 'lucide-react'.
3
+ * lucide-react is an optional peer dependency — the host application installs it.
4
+ * This stub satisfies TypeScript when the plugin is type-checked in isolation.
5
+ */
6
+ declare module 'lucide-react' {
7
+ import type { FC, SVGProps } from 'react'
8
+ type LucideIcon = FC<SVGProps<SVGSVGElement> & { size?: number | string; strokeWidth?: number | string }>
9
+ // Export every named icon as LucideIcon so any import resolves cleanly
10
+ const MessageCircle: LucideIcon
11
+ const Send: LucideIcon
12
+ const X: LucideIcon
13
+ const Minimize2: LucideIcon
14
+ const ArrowLeft: LucideIcon
15
+ const Inbox: LucideIcon
16
+ const Plus: LucideIcon
17
+ const Link2: LucideIcon
18
+ const Search: LucideIcon
19
+ const ChevronDown: LucideIcon
20
+ const ChevronUp: LucideIcon
21
+ const Paperclip: LucideIcon
22
+ const Settings: LucideIcon
23
+ const Mail: LucideIcon
24
+ const Bot: LucideIcon
25
+ const Clock: LucideIcon
26
+ const Timer: LucideIcon
27
+ const Globe: LucideIcon
28
+ const FileSignature: LucideIcon
29
+ export {
30
+ MessageCircle, Send, X, Minimize2, ArrowLeft,
31
+ Inbox, Plus, Link2, Search, ChevronDown, ChevronUp, Paperclip,
32
+ Settings, Mail, Bot, Clock, Timer, Globe, FileSignature,
33
+ }
34
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Ambient stub for '@payload-config'.
3
+ * The host application provides the real config; this stub satisfies
4
+ * TypeScript when the plugin is type-checked in isolation.
5
+ */
6
+ declare module '@payload-config' {
7
+ import type { SanitizedConfig } from 'payload'
8
+ const config: Promise<SanitizedConfig>
9
+ export default config
10
+ }
@@ -1,4 +1,5 @@
1
1
  import type { Payload } from 'payload'
2
+ import { dbCreate } from './db'
2
3
 
3
4
  /**
4
5
  * Helper to create an admin notification.
@@ -20,8 +21,7 @@ export async function createAdminNotification(
20
21
  collectionSlug = 'admin-notifications',
21
22
  ): Promise<void> {
22
23
  try {
23
- await payload.create({
24
- collection: collectionSlug as any,
24
+ await dbCreate(payload, collectionSlug, {
25
25
  data: {
26
26
  title: data.title,
27
27
  message: data.message,