@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.
- package/README.md +357 -435
- package/dist/components/TicketConversation/RewriteDropdown.cjs +98 -0
- package/dist/components/TicketConversation/RewriteDropdown.js +93 -0
- package/dist/components/TicketConversation/SkeletonText.cjs +21 -0
- package/dist/components/TicketConversation/SkeletonText.js +20 -0
- package/dist/components/TicketConversation/constants.cjs +27 -0
- package/dist/components/TicketConversation/constants.js +26 -1
- package/dist/components/TicketConversation/hooks/useDocumentIdFromUrl.cjs +20 -0
- package/dist/components/TicketConversation/hooks/useDocumentIdFromUrl.js +19 -0
- package/dist/components/TicketConversation/index.cjs +10 -147
- package/dist/components/TicketConversation/index.js +4 -141
- package/dist/index.cjs +1763 -677
- package/dist/index.d.cts +4 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +1760 -677
- package/dist/views/BillingView/client.cjs +40 -54
- package/dist/views/BillingView/client.js +40 -54
- package/dist/views/ChatView/client.cjs +4 -3
- package/dist/views/ChatView/client.js +4 -3
- package/dist/views/CrmView/client.cjs +7 -7
- package/dist/views/CrmView/client.js +7 -7
- package/dist/views/EmailTrackingView/client.cjs +6 -5
- package/dist/views/EmailTrackingView/client.js +6 -5
- package/dist/views/ImportConversationView/client.cjs +4 -4
- package/dist/views/ImportConversationView/client.js +4 -4
- package/dist/views/LogsView/client.cjs +3 -2
- package/dist/views/LogsView/client.js +3 -2
- package/dist/views/NewTicketView/client.cjs +1 -1
- package/dist/views/NewTicketView/client.js +1 -1
- package/dist/views/PendingEmailsView/client.cjs +7 -6
- package/dist/views/PendingEmailsView/client.js +7 -6
- package/dist/views/SupportDashboardView/client.cjs +19 -22
- package/dist/views/SupportDashboardView/client.js +19 -22
- package/dist/views/TicketDetailView/NextActionItem.cjs +40 -0
- package/dist/views/TicketDetailView/NextActionItem.js +34 -0
- package/dist/views/TicketDetailView/RewriteDropdown.cjs +80 -0
- package/dist/views/TicketDetailView/RewriteDropdown.js +78 -0
- package/dist/views/TicketDetailView/client.cjs +59 -190
- package/dist/views/TicketDetailView/client.js +52 -183
- package/dist/views/TicketDetailView/constants.cjs +20 -0
- package/dist/views/TicketDetailView/constants.js +16 -0
- package/dist/views/TicketDetailView/helpers.cjs +23 -0
- package/dist/views/TicketDetailView/helpers.js +20 -0
- package/dist/views/TicketDetailView/types.cjs +2 -0
- package/dist/views/TicketDetailView/types.js +1 -0
- package/dist/views/TicketInboxView/client.cjs +14 -10
- package/dist/views/TicketInboxView/client.js +14 -10
- package/dist/views/TicketingSettingsView/client.cjs +101 -96
- package/dist/views/TicketingSettingsView/client.js +92 -88
- package/dist/views/TimeDashboardView/client.cjs +2 -2
- package/dist/views/TimeDashboardView/client.js +2 -2
- package/dist/views/shared/StatusPill.cjs +23 -12
- package/dist/views/shared/StatusPill.js +23 -12
- package/dist/views/shared/dateLocale.cjs +5 -0
- package/dist/views/shared/dateLocale.js +3 -0
- package/package.json +28 -15
- package/src/__tests__/generateTrackingToken.test.ts +53 -0
- package/src/__tests__/integration/ai-agent.test.ts +46 -0
- package/src/__tests__/integration/auth.test.ts +43 -0
- package/src/__tests__/integration/automation-rules.test.ts +68 -0
- package/src/__tests__/integration/buildTestPayload.ts +52 -0
- package/src/__tests__/integration/channels.test.ts +52 -0
- package/src/__tests__/integration/core-behaviors.test.ts +58 -0
- package/src/__tests__/integration/dashboard-volume.test.ts +31 -0
- package/src/__tests__/integration/digest.test.ts +48 -0
- package/src/__tests__/integration/invoice-pdf.test.ts +30 -0
- package/src/__tests__/integration/isolation.test.ts +81 -0
- package/src/__tests__/integration/mentions-invoice.test.ts +50 -0
- package/src/__tests__/integration/nps.test.ts +37 -0
- package/src/__tests__/integration/per-team-sla.test.ts +44 -0
- package/src/__tests__/integration/push.test.ts +75 -0
- package/src/__tests__/integration/sanitization.test.ts +35 -0
- package/src/__tests__/integration/sla-pause.test.ts +39 -0
- package/src/__tests__/integration/smoke.test.ts +26 -0
- package/src/__tests__/integration/snooze.test.ts +68 -0
- package/src/__tests__/integration/teams.test.ts +38 -0
- package/src/__tests__/rateLimiter.test.ts +89 -0
- package/src/__tests__/sanitizeHtml.test.ts +165 -0
- package/src/__tests__/sla.test.ts +258 -0
- package/src/collections/AutomationRules.ts +66 -0
- package/src/collections/NotificationQueue.ts +30 -0
- package/src/collections/PushSubscription.ts +28 -0
- package/src/collections/SatisfactionSurveys.ts +11 -2
- package/src/collections/SlaPolicies.ts +10 -0
- package/src/collections/SupportClients.ts +58 -1
- package/src/collections/SupportTeam.ts +27 -0
- package/src/collections/TicketActivityLog.ts +9 -4
- package/src/collections/TicketMessages.ts +140 -31
- package/src/collections/Tickets.ts +31 -73
- package/src/collections/index.ts +4 -0
- package/src/components/TicketConversation/RewriteDropdown.tsx +86 -0
- package/src/components/TicketConversation/SkeletonText.tsx +23 -0
- package/src/components/TicketConversation/constants.ts +29 -0
- package/src/components/TicketConversation/hooks/useDocumentIdFromUrl.ts +21 -0
- package/src/components/TicketConversation/index.tsx +4 -152
- package/src/endpoints/admin-chat-stream.ts +3 -4
- package/src/endpoints/admin-chat.ts +11 -20
- package/src/endpoints/admin-stats.ts +77 -35
- package/src/endpoints/ai-agent.ts +36 -0
- package/src/endpoints/apply-macro.ts +8 -14
- package/src/endpoints/auth-2fa.ts +12 -12
- package/src/endpoints/auto-close.ts +6 -10
- package/src/endpoints/billing.ts +17 -4
- package/src/endpoints/bulk-action.ts +7 -12
- package/src/endpoints/channels.ts +89 -0
- package/src/endpoints/chat-stream.ts +3 -4
- package/src/endpoints/chat.ts +7 -12
- package/src/endpoints/chatbot.ts +2 -2
- package/src/endpoints/client-intelligence.ts +4 -4
- package/src/endpoints/delete-account.ts +9 -16
- package/src/endpoints/email-stats.ts +2 -2
- package/src/endpoints/escalate.ts +2 -2
- package/src/endpoints/export-csv.ts +2 -2
- package/src/endpoints/export-data.ts +5 -8
- package/src/endpoints/import-conversation.ts +6 -10
- package/src/endpoints/index.ts +22 -1
- package/src/endpoints/invite-collaborator.ts +30 -15
- package/src/endpoints/invoice.ts +127 -0
- package/src/endpoints/kb-search.ts +2 -2
- package/src/endpoints/login.ts +12 -4
- package/src/endpoints/merge-clients.ts +14 -19
- package/src/endpoints/merge-tickets.ts +11 -18
- package/src/endpoints/oauth-google.ts +86 -39
- package/src/endpoints/pending-emails-process.ts +15 -25
- package/src/endpoints/process-digests.ts +93 -0
- package/src/endpoints/process-scheduled.ts +6 -10
- package/src/endpoints/process-snooze.ts +78 -0
- package/src/endpoints/purge-logs.ts +2 -2
- package/src/endpoints/push.ts +49 -0
- package/src/endpoints/resend-notification.ts +3 -4
- package/src/endpoints/round-robin-config.ts +3 -4
- package/src/endpoints/satisfaction.ts +21 -14
- package/src/endpoints/search.ts +5 -8
- package/src/endpoints/seed-kb.ts +3 -4
- package/src/endpoints/send-reminder.ts +6 -9
- package/src/endpoints/settings.ts +5 -2
- package/src/endpoints/signature.ts +3 -4
- package/src/endpoints/sla-check.ts +2 -2
- package/src/endpoints/split-ticket.ts +7 -12
- package/src/endpoints/statuses.ts +2 -2
- package/src/endpoints/ticket-feedback.ts +4 -6
- package/src/endpoints/ticket-synthesis.ts +2 -2
- package/src/endpoints/track-open.ts +16 -28
- package/src/endpoints/transfer-ticket.ts +21 -9
- package/src/endpoints/user-prefs.ts +3 -4
- package/src/hooks/automationRules.ts +84 -0
- package/src/hooks/checkSLA.ts +63 -17
- package/src/plugin.ts +8 -0
- package/src/portal/LiveChat.tsx +2 -63
- package/src/portal/auth/tickets/detail/CloseTicketButton.tsx +10 -0
- package/src/portal/auth/tickets/detail/MarkSolutionButton.tsx +14 -2
- package/src/portal/auth/tickets/detail/MessageActions.tsx +37 -18
- package/src/portal/auth/tickets/detail/ReopenTicketButton.tsx +10 -0
- package/src/portal/auth/tickets/detail/TransferAndInviteActions.tsx +3 -2
- package/src/portal/auth/tickets/detail/page.tsx +2 -2
- package/src/portal/helpers.ts +46 -0
- package/src/portal/login/page.tsx +3 -8
- package/src/portal/types.ts +15 -0
- package/src/types/lucide-react.d.ts +34 -0
- package/src/types/payload-config.d.ts +10 -0
- package/src/utils/adminNotification.ts +2 -2
- package/src/utils/aiAgent.ts +147 -0
- package/src/utils/auth.ts +5 -4
- package/src/utils/channels.ts +36 -0
- package/src/utils/db.ts +73 -0
- package/src/utils/emailTemplate.ts +8 -1
- package/src/utils/fireWebhooks.ts +4 -6
- package/src/utils/generateTicketSynthesis.ts +7 -12
- package/src/utils/invoicePdf.ts +88 -0
- package/src/utils/notificationQueue.ts +36 -0
- package/src/utils/push.ts +63 -0
- package/src/utils/readSettings.ts +22 -6
- package/src/utils/sanitizeHtml.ts +57 -0
- package/src/utils/slugs.ts +8 -0
- package/src/utils/teamAccess.ts +27 -0
- package/src/utils/ticketAccess.ts +53 -0
- package/src/utils/webhookDispatcher.ts +4 -6
- package/src/views/BillingView/client.tsx +49 -37
- package/src/views/ChatView/client.tsx +4 -3
- package/src/views/CrmView/client.tsx +7 -7
- package/src/views/EmailTrackingView/client.tsx +7 -6
- package/src/views/ImportConversationView/client.tsx +4 -4
- package/src/views/LogsView/client.tsx +3 -2
- package/src/views/NewTicketView/client.tsx +1 -1
- package/src/views/PendingEmailsView/client.tsx +7 -6
- package/src/views/SupportDashboardView/client.tsx +21 -21
- package/src/views/TicketDetailView/NextActionItem.tsx +30 -0
- package/src/views/TicketDetailView/RewriteDropdown.tsx +63 -0
- package/src/views/TicketDetailView/client.tsx +54 -179
- package/src/views/TicketDetailView/constants.ts +23 -0
- package/src/views/TicketDetailView/helpers.ts +17 -0
- package/src/views/TicketDetailView/types.ts +10 -0
- package/src/views/TicketInboxView/client.tsx +16 -10
- package/src/views/TicketingSettingsView/client.tsx +90 -87
- package/src/views/TimeDashboardView/client.tsx +2 -2
- package/src/views/shared/StatusPill.tsx +24 -12
- package/src/views/shared/dateLocale.ts +5 -0
- package/src/views/shared/locales/en.json +708 -0
- package/src/views/shared/locales/fr.json +708 -0
package/src/hooks/checkSLA.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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)
|
package/src/portal/LiveChat.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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
|
|
106
|
-
<
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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:
|
|
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:
|
|
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
|
-
//
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
24
|
-
collection: collectionSlug as any,
|
|
24
|
+
await dbCreate(payload, collectionSlug, {
|
|
25
25
|
data: {
|
|
26
26
|
title: data.title,
|
|
27
27
|
message: data.message,
|