@consilioweb/payload-support 0.16.0 → 1.0.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 (193) hide show
  1. package/README.md +347 -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 +1604 -671
  13. package/dist/index.d.cts +3 -0
  14. package/dist/index.d.ts +3 -0
  15. package/dist/index.js +1602 -671
  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 +23 -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/sanitization.test.ts +35 -0
  71. package/src/__tests__/integration/sla-pause.test.ts +39 -0
  72. package/src/__tests__/integration/smoke.test.ts +26 -0
  73. package/src/__tests__/integration/snooze.test.ts +68 -0
  74. package/src/__tests__/integration/teams.test.ts +38 -0
  75. package/src/__tests__/rateLimiter.test.ts +89 -0
  76. package/src/__tests__/sanitizeHtml.test.ts +165 -0
  77. package/src/__tests__/sla.test.ts +258 -0
  78. package/src/collections/AutomationRules.ts +66 -0
  79. package/src/collections/NotificationQueue.ts +30 -0
  80. package/src/collections/SatisfactionSurveys.ts +11 -2
  81. package/src/collections/SupportClients.ts +58 -1
  82. package/src/collections/SupportTeam.ts +27 -0
  83. package/src/collections/TicketActivityLog.ts +9 -4
  84. package/src/collections/TicketMessages.ts +129 -31
  85. package/src/collections/Tickets.ts +31 -73
  86. package/src/collections/index.ts +3 -0
  87. package/src/components/TicketConversation/RewriteDropdown.tsx +86 -0
  88. package/src/components/TicketConversation/SkeletonText.tsx +23 -0
  89. package/src/components/TicketConversation/constants.ts +29 -0
  90. package/src/components/TicketConversation/hooks/useDocumentIdFromUrl.ts +21 -0
  91. package/src/components/TicketConversation/index.tsx +4 -152
  92. package/src/endpoints/admin-chat-stream.ts +3 -4
  93. package/src/endpoints/admin-chat.ts +11 -20
  94. package/src/endpoints/admin-stats.ts +70 -32
  95. package/src/endpoints/ai-agent.ts +36 -0
  96. package/src/endpoints/apply-macro.ts +8 -14
  97. package/src/endpoints/auth-2fa.ts +12 -12
  98. package/src/endpoints/auto-close.ts +6 -10
  99. package/src/endpoints/billing.ts +17 -4
  100. package/src/endpoints/bulk-action.ts +7 -12
  101. package/src/endpoints/channels.ts +89 -0
  102. package/src/endpoints/chat-stream.ts +3 -4
  103. package/src/endpoints/chat.ts +7 -12
  104. package/src/endpoints/chatbot.ts +2 -2
  105. package/src/endpoints/client-intelligence.ts +4 -4
  106. package/src/endpoints/delete-account.ts +9 -16
  107. package/src/endpoints/email-stats.ts +2 -2
  108. package/src/endpoints/escalate.ts +2 -2
  109. package/src/endpoints/export-csv.ts +2 -2
  110. package/src/endpoints/export-data.ts +5 -8
  111. package/src/endpoints/import-conversation.ts +6 -10
  112. package/src/endpoints/index.ts +18 -1
  113. package/src/endpoints/invite-collaborator.ts +30 -15
  114. package/src/endpoints/invoice.ts +127 -0
  115. package/src/endpoints/kb-search.ts +2 -2
  116. package/src/endpoints/login.ts +12 -4
  117. package/src/endpoints/merge-clients.ts +14 -19
  118. package/src/endpoints/merge-tickets.ts +11 -18
  119. package/src/endpoints/oauth-google.ts +86 -39
  120. package/src/endpoints/pending-emails-process.ts +15 -25
  121. package/src/endpoints/process-digests.ts +93 -0
  122. package/src/endpoints/process-scheduled.ts +6 -10
  123. package/src/endpoints/process-snooze.ts +78 -0
  124. package/src/endpoints/purge-logs.ts +2 -2
  125. package/src/endpoints/resend-notification.ts +3 -4
  126. package/src/endpoints/round-robin-config.ts +3 -4
  127. package/src/endpoints/satisfaction.ts +21 -14
  128. package/src/endpoints/search.ts +5 -8
  129. package/src/endpoints/seed-kb.ts +3 -4
  130. package/src/endpoints/send-reminder.ts +6 -9
  131. package/src/endpoints/settings.ts +5 -2
  132. package/src/endpoints/signature.ts +3 -4
  133. package/src/endpoints/sla-check.ts +2 -2
  134. package/src/endpoints/split-ticket.ts +7 -12
  135. package/src/endpoints/statuses.ts +2 -2
  136. package/src/endpoints/ticket-feedback.ts +4 -6
  137. package/src/endpoints/ticket-synthesis.ts +2 -2
  138. package/src/endpoints/track-open.ts +16 -28
  139. package/src/endpoints/transfer-ticket.ts +21 -9
  140. package/src/endpoints/user-prefs.ts +3 -4
  141. package/src/hooks/automationRules.ts +84 -0
  142. package/src/hooks/checkSLA.ts +48 -16
  143. package/src/plugin.ts +6 -0
  144. package/src/portal/LiveChat.tsx +2 -63
  145. package/src/portal/auth/tickets/detail/CloseTicketButton.tsx +10 -0
  146. package/src/portal/auth/tickets/detail/MarkSolutionButton.tsx +14 -2
  147. package/src/portal/auth/tickets/detail/MessageActions.tsx +37 -18
  148. package/src/portal/auth/tickets/detail/ReopenTicketButton.tsx +10 -0
  149. package/src/portal/auth/tickets/detail/TransferAndInviteActions.tsx +3 -2
  150. package/src/portal/auth/tickets/detail/page.tsx +2 -2
  151. package/src/portal/helpers.ts +46 -0
  152. package/src/portal/login/page.tsx +3 -8
  153. package/src/portal/types.ts +15 -0
  154. package/src/types/lucide-react.d.ts +34 -0
  155. package/src/types/payload-config.d.ts +10 -0
  156. package/src/utils/adminNotification.ts +2 -2
  157. package/src/utils/aiAgent.ts +147 -0
  158. package/src/utils/auth.ts +5 -4
  159. package/src/utils/channels.ts +36 -0
  160. package/src/utils/db.ts +73 -0
  161. package/src/utils/emailTemplate.ts +8 -1
  162. package/src/utils/fireWebhooks.ts +4 -6
  163. package/src/utils/generateTicketSynthesis.ts +7 -12
  164. package/src/utils/invoicePdf.ts +88 -0
  165. package/src/utils/notificationQueue.ts +36 -0
  166. package/src/utils/readSettings.ts +22 -6
  167. package/src/utils/sanitizeHtml.ts +57 -0
  168. package/src/utils/slugs.ts +6 -0
  169. package/src/utils/teamAccess.ts +27 -0
  170. package/src/utils/ticketAccess.ts +53 -0
  171. package/src/utils/webhookDispatcher.ts +4 -6
  172. package/src/views/BillingView/client.tsx +49 -37
  173. package/src/views/ChatView/client.tsx +4 -3
  174. package/src/views/CrmView/client.tsx +7 -7
  175. package/src/views/EmailTrackingView/client.tsx +7 -6
  176. package/src/views/ImportConversationView/client.tsx +4 -4
  177. package/src/views/LogsView/client.tsx +3 -2
  178. package/src/views/NewTicketView/client.tsx +1 -1
  179. package/src/views/PendingEmailsView/client.tsx +7 -6
  180. package/src/views/SupportDashboardView/client.tsx +21 -21
  181. package/src/views/TicketDetailView/NextActionItem.tsx +30 -0
  182. package/src/views/TicketDetailView/RewriteDropdown.tsx +63 -0
  183. package/src/views/TicketDetailView/client.tsx +54 -179
  184. package/src/views/TicketDetailView/constants.ts +23 -0
  185. package/src/views/TicketDetailView/helpers.ts +17 -0
  186. package/src/views/TicketDetailView/types.ts +10 -0
  187. package/src/views/TicketInboxView/client.tsx +16 -10
  188. package/src/views/TicketingSettingsView/client.tsx +90 -87
  189. package/src/views/TimeDashboardView/client.tsx +2 -2
  190. package/src/views/shared/StatusPill.tsx +24 -12
  191. package/src/views/shared/dateLocale.ts +5 -0
  192. package/src/views/shared/locales/en.json +708 -0
  193. package/src/views/shared/locales/fr.json +708 -0
@@ -0,0 +1,84 @@
1
+ import type { CollectionAfterChangeHook } from 'payload'
2
+ import type { CollectionSlugs } from '../utils/slugs'
3
+ import { dbFind } from '../utils/db'
4
+
5
+ interface Condition { field?: string; operator?: string; value?: string }
6
+ interface Action { type?: string; value?: string }
7
+ interface Rule { matchType?: string; conditions?: Condition[]; actions?: Action[] }
8
+
9
+ function evaluateConditions(doc: Record<string, unknown>, rule: Rule): boolean {
10
+ const conds = Array.isArray(rule.conditions) ? rule.conditions : []
11
+ if (conds.length === 0) return true
12
+ const results = conds.map((c) => {
13
+ const fieldVal = String(doc[c.field as string] ?? '')
14
+ const v = String(c.value ?? '')
15
+ switch (c.operator) {
16
+ case 'equals': return fieldVal === v
17
+ case 'not_equals': return fieldVal !== v
18
+ case 'contains': return fieldVal.toLowerCase().includes(v.toLowerCase())
19
+ default: return false
20
+ }
21
+ })
22
+ return rule.matchType === 'any' ? results.some(Boolean) : results.every(Boolean)
23
+ }
24
+
25
+ /**
26
+ * Evaluate enabled automation rules against a ticket and apply their actions.
27
+ * Runs as afterChange on the Tickets collection. All matched actions are applied
28
+ * in a SINGLE update flagged `skipAutomation` to avoid re-triggering (no loop).
29
+ */
30
+ export function createApplyAutomationRules(slugs: CollectionSlugs): CollectionAfterChangeHook {
31
+ return async ({ doc, previousDoc, operation, req }) => {
32
+ if (req.context?.skipAutomation) return doc
33
+
34
+ const events: string[] = []
35
+ if (operation === 'create') events.push('ticket_created')
36
+ if (operation === 'update') {
37
+ events.push('ticket_updated')
38
+ if (previousDoc && previousDoc.status !== doc.status) events.push('ticket_status_changed')
39
+ }
40
+ if (events.length === 0) return doc
41
+
42
+ try {
43
+ const rules = await dbFind<Rule & { actions?: Action[] }>(req.payload, slugs.automationRules, {
44
+ where: { and: [{ enabled: { equals: true } }, { event: { in: events } }] },
45
+ sort: 'order',
46
+ limit: 200,
47
+ depth: 0,
48
+ overrideAccess: true,
49
+ })
50
+
51
+ const changes: Record<string, unknown> = {}
52
+ const tags: string[] = Array.isArray(doc.tags) ? [...(doc.tags as string[])] : []
53
+ let tagsTouched = false
54
+
55
+ for (const rule of rules.docs) {
56
+ if (!evaluateConditions(doc as Record<string, unknown>, rule)) continue
57
+ for (const a of Array.isArray(rule.actions) ? rule.actions : []) {
58
+ if (!a.value) continue
59
+ switch (a.type) {
60
+ case 'set_status': changes.status = a.value; break
61
+ case 'set_priority': changes.priority = a.value; break
62
+ case 'set_category': changes.category = a.value; break
63
+ case 'assign': changes.assignedTo = a.value; break
64
+ case 'add_tag': if (!tags.includes(a.value)) { tags.push(a.value); tagsTouched = true } break
65
+ }
66
+ }
67
+ }
68
+
69
+ if (tagsTouched) changes.tags = tags
70
+ if (Object.keys(changes).length > 0) {
71
+ await req.payload.update({
72
+ collection: slugs.tickets,
73
+ id: doc.id,
74
+ data: changes,
75
+ overrideAccess: true,
76
+ context: { skipAutomation: true },
77
+ })
78
+ }
79
+ } catch (err) {
80
+ console.error('[support] Failed to apply automation rules:', err)
81
+ }
82
+ return doc
83
+ }
84
+ }
@@ -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,
@@ -160,8 +160,7 @@ async function resolveSlaPolicy(
160
160
  // Look for a default policy matching this ticket's priority
161
161
  const priority = field(ticket, 'priority') || 'normal'
162
162
  try {
163
- const defaults = await payload.find({
164
- collection: slugs.slaPolicies as any,
163
+ const defaults = await dbFind(payload, slugs.slaPolicies, {
165
164
  where: {
166
165
  and: [
167
166
  { isDefault: { equals: true } },
@@ -218,8 +217,7 @@ async function handleSlaBreach(
218
217
  if (typeof policyRef === 'object' && (policyRef as AnyDoc).escalateOnBreach !== undefined) {
219
218
  policy = policyRef
220
219
  } else {
221
- policy = await payload.findByID({
222
- collection: slugs.slaPolicies as any,
220
+ policy = await dbFindByID(payload, slugs.slaPolicies, {
223
221
  id: policyId as number,
224
222
  depth: 0,
225
223
  overrideAccess: true,
@@ -240,8 +238,7 @@ async function handleSlaBreach(
240
238
 
241
239
  const escalateUser = typeof policy.escalateTo === 'object' && (policy.escalateTo as AnyDoc).email
242
240
  ? policy.escalateTo
243
- : await payload.findByID({
244
- collection: slugs.users as any,
241
+ : await dbFindByID(payload, slugs.users, {
245
242
  id: escalateToId as number,
246
243
  depth: 0,
247
244
  overrideAccess: true,
@@ -312,8 +309,7 @@ export function createAssignSlaDeadlines(slugs: CollectionSlugs, notificationSlu
312
309
  updateData.slaPolicy = policy.id
313
310
  }
314
311
 
315
- await payload.update({
316
- collection: slugs.tickets as any,
312
+ await dbUpdate(payload, slugs.tickets, {
317
313
  id: doc.id,
318
314
  data: updateData,
319
315
  overrideAccess: true,
@@ -346,8 +342,7 @@ export function createCheckSlaOnResolve(slugs: CollectionSlugs, notificationSlug
346
342
  const deadline = new Date(doc.slaResolutionDue as string)
347
343
  const breached = now > deadline
348
344
 
349
- await payload.update({
350
- collection: slugs.tickets as any,
345
+ await dbUpdate(payload, slugs.tickets, {
351
346
  id: doc.id,
352
347
  data: { slaResolutionBreached: breached },
353
348
  overrideAccess: true,
@@ -364,6 +359,45 @@ export function createCheckSlaOnResolve(slugs: CollectionSlugs, notificationSlug
364
359
  }
365
360
  }
366
361
 
362
+ /**
363
+ * SLA pause-on-hold: time spent in `waiting_client` should not count against the
364
+ * resolution SLA (the agent is waiting on the customer). Entering hold stamps
365
+ * `slaPausedAt`; leaving it pushes `slaResolutionDue` forward by the paused span.
366
+ */
367
+ export function createPauseSlaOnHold(slugs: CollectionSlugs): CollectionAfterChangeHook {
368
+ return async ({ doc, previousDoc, operation, req }) => {
369
+ if (operation !== 'update' || !previousDoc) return doc
370
+ if (previousDoc.status === doc.status) return doc
371
+ if (!doc.slaResolutionDue) return doc
372
+
373
+ const wasWaiting = previousDoc.status === 'waiting_client'
374
+ const isWaiting = doc.status === 'waiting_client'
375
+
376
+ try {
377
+ if (!wasWaiting && isWaiting && !doc.slaPausedAt && doc.status !== 'resolved') {
378
+ // Entering hold — stop the resolution clock.
379
+ await req.payload.update({
380
+ collection: slugs.tickets,
381
+ id: doc.id,
382
+ data: { slaPausedAt: new Date().toISOString() },
383
+ overrideAccess: true,
384
+ })
385
+ } else if (wasWaiting && !isWaiting && doc.slaPausedAt) {
386
+ // Leaving hold — extend the deadline by however long we were paused.
387
+ const pausedMs = Date.now() - new Date(doc.slaPausedAt as string).getTime()
388
+ const data: Record<string, unknown> = { slaPausedAt: null }
389
+ if (pausedMs > 0) {
390
+ data.slaResolutionDue = new Date(new Date(doc.slaResolutionDue as string).getTime() + pausedMs).toISOString()
391
+ }
392
+ await req.payload.update({ collection: slugs.tickets, id: doc.id, data, overrideAccess: true })
393
+ }
394
+ } catch (err) {
395
+ console.error('[sla] Failed to pause/resume SLA on hold:', err)
396
+ }
397
+ return doc
398
+ }
399
+ }
400
+
367
401
  /**
368
402
  * Factory: Check SLA breach on first admin response.
369
403
  * Runs as afterChange on ticket-messages collection.
@@ -377,8 +411,7 @@ export function createCheckSlaOnReply(slugs: CollectionSlugs, notificationSlug =
377
411
  const { payload } = req
378
412
  const ticketId = typeof doc.ticket === 'object' ? (doc.ticket as AnyDoc).id : doc.ticket
379
413
 
380
- const ticket = await payload.findByID({
381
- collection: slugs.tickets as any,
414
+ const ticket = await dbFindByID(payload, slugs.tickets, {
382
415
  id: ticketId as number,
383
416
  depth: 1,
384
417
  overrideAccess: true,
@@ -394,8 +427,7 @@ export function createCheckSlaOnReply(slugs: CollectionSlugs, notificationSlug =
394
427
  const deadline = new Date(ticket.slaFirstResponseDue as string)
395
428
  const breached = now > deadline
396
429
 
397
- await payload.update({
398
- collection: slugs.tickets as any,
430
+ await dbUpdate(payload, slugs.tickets, {
399
431
  id: ticketId as number,
400
432
  data: { slaFirstResponseBreached: breached },
401
433
  overrideAccess: true,
package/src/plugin.ts CHANGED
@@ -23,6 +23,9 @@ import {
23
23
  createClientSummariesCollection,
24
24
  createTicketFeedbackCollection,
25
25
  createTicketCollaboratorsCollection,
26
+ createNotificationQueueCollection,
27
+ createAutomationRulesCollection,
28
+ createSupportTeamCollection,
26
29
  } from './collections'
27
30
 
28
31
  function viewConfig(component: string, path: string): AdminViewConfig {
@@ -90,6 +93,9 @@ export function supportPlugin(config?: SupportPluginConfig): Plugin {
90
93
  createKnowledgeBaseCollection(slugs),
91
94
  createTicketFeedbackCollection(slugs, { notificationSlug: config?.notificationSlug }),
92
95
  createTicketCollaboratorsCollection(slugs),
96
+ createNotificationQueueCollection(slugs),
97
+ createAutomationRulesCollection(slugs),
98
+ createSupportTeamCollection(slugs),
93
99
  ]
94
100
 
95
101
  // 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'