@consilioweb/payload-support 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (189) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +525 -0
  3. package/dist/client.cjs +7 -0
  4. package/dist/client.d.cts +3 -0
  5. package/dist/client.d.ts +3 -0
  6. package/dist/client.js +5 -0
  7. package/dist/index.cjs +7766 -0
  8. package/dist/index.d.cts +384 -0
  9. package/dist/index.d.ts +384 -0
  10. package/dist/index.js +7730 -0
  11. package/dist/views.d.cts +30 -0
  12. package/dist/views.d.ts +30 -0
  13. package/package.json +131 -0
  14. package/src/client.ts +1 -0
  15. package/src/collections/AuthLogs.ts +65 -0
  16. package/src/collections/CannedResponses.ts +69 -0
  17. package/src/collections/ChatMessages.ts +98 -0
  18. package/src/collections/EmailLogs.ts +94 -0
  19. package/src/collections/KnowledgeBase.ts +99 -0
  20. package/src/collections/Macros.ts +98 -0
  21. package/src/collections/PendingEmails.ts +122 -0
  22. package/src/collections/SatisfactionSurveys.ts +98 -0
  23. package/src/collections/SlaPolicies.ts +123 -0
  24. package/src/collections/SupportClients.ts +210 -0
  25. package/src/collections/TicketActivityLog.ts +81 -0
  26. package/src/collections/TicketMessages.ts +364 -0
  27. package/src/collections/TicketStatuses.ts +108 -0
  28. package/src/collections/Tickets.ts +704 -0
  29. package/src/collections/TimeEntries.ts +105 -0
  30. package/src/collections/WebhookEndpoints.ts +96 -0
  31. package/src/collections/index.ts +16 -0
  32. package/src/components/TicketConversation/components/AISummaryPanel.tsx +85 -0
  33. package/src/components/TicketConversation/components/ActionPanels.tsx +140 -0
  34. package/src/components/TicketConversation/components/ActivityLog.tsx +39 -0
  35. package/src/components/TicketConversation/components/ClientBar.tsx +37 -0
  36. package/src/components/TicketConversation/components/ClientHistory.tsx +117 -0
  37. package/src/components/TicketConversation/components/CodeBlock.tsx +186 -0
  38. package/src/components/TicketConversation/components/CodeBlockInserter.tsx +166 -0
  39. package/src/components/TicketConversation/components/QuickActions.tsx +82 -0
  40. package/src/components/TicketConversation/components/TicketHeader.tsx +91 -0
  41. package/src/components/TicketConversation/components/TimeTrackingPanel.tsx +161 -0
  42. package/src/components/TicketConversation/config.ts +82 -0
  43. package/src/components/TicketConversation/constants.ts +74 -0
  44. package/src/components/TicketConversation/context.ts +63 -0
  45. package/src/components/TicketConversation/hooks/useAI.ts +180 -0
  46. package/src/components/TicketConversation/hooks/useMessageActions.ts +131 -0
  47. package/src/components/TicketConversation/hooks/useReply.ts +190 -0
  48. package/src/components/TicketConversation/hooks/useTicketActions.ts +205 -0
  49. package/src/components/TicketConversation/hooks/useTimeTracking.ts +107 -0
  50. package/src/components/TicketConversation/hooks/useTranslation.ts +116 -0
  51. package/src/components/TicketConversation/index.tsx +1110 -0
  52. package/src/components/TicketConversation/locales/en.json +878 -0
  53. package/src/components/TicketConversation/locales/fr.json +878 -0
  54. package/src/components/TicketConversation/types.ts +54 -0
  55. package/src/components/TicketConversation/utils.ts +25 -0
  56. package/src/endpoints/admin-chat-stream.ts +238 -0
  57. package/src/endpoints/admin-chat.ts +263 -0
  58. package/src/endpoints/admin-stats.ts +200 -0
  59. package/src/endpoints/ai.ts +199 -0
  60. package/src/endpoints/apply-macro.ts +144 -0
  61. package/src/endpoints/auth-2fa.ts +163 -0
  62. package/src/endpoints/auto-close.ts +175 -0
  63. package/src/endpoints/billing.ts +167 -0
  64. package/src/endpoints/bulk-action.ts +103 -0
  65. package/src/endpoints/chat-stream.ts +127 -0
  66. package/src/endpoints/chat.ts +188 -0
  67. package/src/endpoints/chatbot.ts +113 -0
  68. package/src/endpoints/delete-account.ts +129 -0
  69. package/src/endpoints/email-stats.ts +109 -0
  70. package/src/endpoints/export-csv.ts +84 -0
  71. package/src/endpoints/export-data.ts +104 -0
  72. package/src/endpoints/import-conversation.ts +307 -0
  73. package/src/endpoints/index.ts +154 -0
  74. package/src/endpoints/login.ts +92 -0
  75. package/src/endpoints/merge-clients.ts +132 -0
  76. package/src/endpoints/merge-tickets.ts +137 -0
  77. package/src/endpoints/oauth-google.ts +179 -0
  78. package/src/endpoints/pending-emails-process.ts +224 -0
  79. package/src/endpoints/presence.ts +104 -0
  80. package/src/endpoints/process-scheduled.ts +144 -0
  81. package/src/endpoints/purge-logs.ts +58 -0
  82. package/src/endpoints/resend-notification.ts +99 -0
  83. package/src/endpoints/round-robin-config.ts +92 -0
  84. package/src/endpoints/satisfaction.ts +93 -0
  85. package/src/endpoints/search.ts +106 -0
  86. package/src/endpoints/seed-kb.ts +153 -0
  87. package/src/endpoints/settings.ts +144 -0
  88. package/src/endpoints/signature.ts +93 -0
  89. package/src/endpoints/sla-check.ts +124 -0
  90. package/src/endpoints/split-ticket.ts +131 -0
  91. package/src/endpoints/statuses.ts +45 -0
  92. package/src/endpoints/track-open.ts +154 -0
  93. package/src/endpoints/typing.ts +101 -0
  94. package/src/endpoints/user-prefs.ts +125 -0
  95. package/src/hooks/checkSLA.ts +414 -0
  96. package/src/hooks/ticketStatusEmail.ts +182 -0
  97. package/src/index.ts +51 -0
  98. package/src/plugin.ts +157 -0
  99. package/src/portal/LiveChat.tsx +1353 -0
  100. package/src/portal/auth/ChatWidget.tsx +350 -0
  101. package/src/portal/auth/ChatbotWidget.tsx +285 -0
  102. package/src/portal/auth/SupportHeader.tsx +409 -0
  103. package/src/portal/auth/dashboard/DashboardClient.tsx +650 -0
  104. package/src/portal/auth/dashboard/page.tsx +84 -0
  105. package/src/portal/auth/faq/FAQSearch.tsx +117 -0
  106. package/src/portal/auth/faq/page.tsx +199 -0
  107. package/src/portal/auth/layout.tsx +61 -0
  108. package/src/portal/auth/profile/page.tsx +705 -0
  109. package/src/portal/auth/tickets/detail/CloseTicketButton.tsx +74 -0
  110. package/src/portal/auth/tickets/detail/CollapsibleMessages.tsx +46 -0
  111. package/src/portal/auth/tickets/detail/MarkSolutionButton.tsx +50 -0
  112. package/src/portal/auth/tickets/detail/MessageActions.tsx +158 -0
  113. package/src/portal/auth/tickets/detail/PrintButton.tsx +16 -0
  114. package/src/portal/auth/tickets/detail/ReadReceipt.tsx +34 -0
  115. package/src/portal/auth/tickets/detail/ReopenTicketButton.tsx +74 -0
  116. package/src/portal/auth/tickets/detail/SatisfactionForm.tsx +156 -0
  117. package/src/portal/auth/tickets/detail/TicketPolling.tsx +57 -0
  118. package/src/portal/auth/tickets/detail/TicketReplyForm.tsx +294 -0
  119. package/src/portal/auth/tickets/detail/TypingIndicator.tsx +58 -0
  120. package/src/portal/auth/tickets/detail/page.tsx +738 -0
  121. package/src/portal/auth/tickets/new/page.tsx +515 -0
  122. package/src/portal/forgot-password/page.tsx +114 -0
  123. package/src/portal/layout.tsx +26 -0
  124. package/src/portal/locales/en.json +374 -0
  125. package/src/portal/locales/fr.json +374 -0
  126. package/src/portal/login/page.tsx +351 -0
  127. package/src/portal/page.tsx +162 -0
  128. package/src/portal/register/page.tsx +281 -0
  129. package/src/portal/reset-password/page.tsx +152 -0
  130. package/src/styles/BillingView.module.scss +311 -0
  131. package/src/styles/ChatView.module.scss +438 -0
  132. package/src/styles/CommandPalette.module.scss +160 -0
  133. package/src/styles/CrmView.module.scss +554 -0
  134. package/src/styles/EmailTracking.module.scss +238 -0
  135. package/src/styles/ImportConversation.module.scss +267 -0
  136. package/src/styles/Layout.module.scss +55 -0
  137. package/src/styles/Logs.module.scss +164 -0
  138. package/src/styles/NewTicket.module.scss +143 -0
  139. package/src/styles/PendingEmails.module.scss +629 -0
  140. package/src/styles/SupportDashboard.module.scss +649 -0
  141. package/src/styles/TicketDetail.module.scss +1043 -0
  142. package/src/styles/TicketInbox.module.scss +296 -0
  143. package/src/styles/TicketingSettings.module.scss +358 -0
  144. package/src/styles/TimeDashboard.module.scss +287 -0
  145. package/src/styles/_tokens.scss +78 -0
  146. package/src/styles/theme.css +633 -0
  147. package/src/types.ts +255 -0
  148. package/src/utils/adminNotification.ts +38 -0
  149. package/src/utils/auth.ts +46 -0
  150. package/src/utils/emailTemplate.ts +343 -0
  151. package/src/utils/fireWebhooks.ts +84 -0
  152. package/src/utils/index.ts +22 -0
  153. package/src/utils/rateLimiter.ts +52 -0
  154. package/src/utils/readSettings.ts +67 -0
  155. package/src/utils/slugs.ts +54 -0
  156. package/src/utils/webhookDispatcher.ts +120 -0
  157. package/src/views/BillingView/client.tsx +137 -0
  158. package/src/views/BillingView/index.tsx +33 -0
  159. package/src/views/ChatView/client.tsx +294 -0
  160. package/src/views/ChatView/index.tsx +33 -0
  161. package/src/views/CrmView/client.tsx +206 -0
  162. package/src/views/CrmView/index.tsx +33 -0
  163. package/src/views/EmailTrackingView/client.tsx +124 -0
  164. package/src/views/EmailTrackingView/index.tsx +33 -0
  165. package/src/views/ImportConversationView/client.tsx +133 -0
  166. package/src/views/ImportConversationView/index.tsx +33 -0
  167. package/src/views/LogsView/client.tsx +151 -0
  168. package/src/views/LogsView/index.tsx +30 -0
  169. package/src/views/NewTicketView/client.tsx +227 -0
  170. package/src/views/NewTicketView/index.tsx +30 -0
  171. package/src/views/PendingEmailsView/client.tsx +177 -0
  172. package/src/views/PendingEmailsView/index.tsx +33 -0
  173. package/src/views/SupportDashboardView/client.tsx +424 -0
  174. package/src/views/SupportDashboardView/index.tsx +33 -0
  175. package/src/views/TicketDetailView/client.tsx +775 -0
  176. package/src/views/TicketDetailView/index.tsx +33 -0
  177. package/src/views/TicketInboxView/client.tsx +313 -0
  178. package/src/views/TicketInboxView/index.tsx +30 -0
  179. package/src/views/TicketingSettingsView/client.tsx +866 -0
  180. package/src/views/TicketingSettingsView/index.tsx +33 -0
  181. package/src/views/TimeDashboardView/client.tsx +144 -0
  182. package/src/views/TimeDashboardView/index.tsx +33 -0
  183. package/src/views/shared/AdminViewHeader.tsx +69 -0
  184. package/src/views/shared/ErrorBoundary.tsx +68 -0
  185. package/src/views/shared/Skeleton.tsx +125 -0
  186. package/src/views/shared/adminTokens.ts +37 -0
  187. package/src/views/shared/config.ts +82 -0
  188. package/src/views/shared/index.ts +6 -0
  189. package/src/views.ts +16 -0
package/src/types.ts ADDED
@@ -0,0 +1,255 @@
1
+ // ─── Feature flags ───────────────────────────────────────
2
+
3
+ export interface SupportFeatures {
4
+ /** Time tracking: timer, manual entries, billing */
5
+ timeTracking?: boolean
6
+ /** AI features: sentiment, synthesis, suggestion, rewrite */
7
+ ai?: boolean
8
+ /** Satisfaction surveys: CSAT rating after resolution */
9
+ satisfaction?: boolean
10
+ /** Live chat integration: chat → ticket conversion */
11
+ chat?: boolean
12
+ /** Email tracking: pixel tracking, open/sent status per message */
13
+ emailTracking?: boolean
14
+ /** Canned responses: quick reply templates */
15
+ canned?: boolean
16
+ /** Ticket merge: combine two tickets into one */
17
+ merge?: boolean
18
+ /** Snooze: temporarily hide a ticket */
19
+ snooze?: boolean
20
+ /** External messages: add messages received outside the system */
21
+ externalMessages?: boolean
22
+ /** Client history: past tickets, projects, notes sidebar */
23
+ clientHistory?: boolean
24
+ /** Activity log: audit trail of actions on the ticket */
25
+ activityLog?: boolean
26
+ /** Split ticket: extract a message into a new ticket */
27
+ splitTicket?: boolean
28
+ /** Scheduled replies: send a message at a future date */
29
+ scheduledReplies?: boolean
30
+ /** Auto-close: automatically resolve inactive tickets */
31
+ autoClose?: boolean
32
+ /** Auto-close delay in days */
33
+ autoCloseDays?: number
34
+ /** Round-robin: distribute new tickets evenly among agents */
35
+ roundRobin?: boolean
36
+ /** SLA policies: response & resolution time targets */
37
+ sla?: boolean
38
+ /** Webhooks: outbound HTTP hooks on ticket events */
39
+ webhooks?: boolean
40
+ /** Macros: multi-action shortcuts */
41
+ macros?: boolean
42
+ /** Custom statuses: configurable ticket statuses */
43
+ customStatuses?: boolean
44
+ /** Collision detection: warn when multiple agents view same ticket */
45
+ collisionDetection?: boolean
46
+ /** Per-agent email signatures */
47
+ signatures?: boolean
48
+ /** AI chatbot for self-service */
49
+ chatbot?: boolean
50
+ /** Bulk actions on multiple tickets */
51
+ bulkActions?: boolean
52
+ /** Command palette (⌘K) */
53
+ commandPalette?: boolean
54
+ /** Knowledge base / FAQ */
55
+ knowledgeBase?: boolean
56
+ /** Pending email queue */
57
+ pendingEmails?: boolean
58
+ /** Authentication audit logs */
59
+ authLogs?: boolean
60
+ }
61
+
62
+ // ─── AI provider ─────────────────────────────────────────
63
+
64
+ export interface AIProviderConfig {
65
+ provider: 'anthropic' | 'openai' | 'ollama' | 'custom'
66
+ apiKey?: string
67
+ model?: string
68
+ baseUrl?: string
69
+ }
70
+
71
+ // ─── Email configuration ─────────────────────────────────
72
+
73
+ export interface EmailConfig {
74
+ fromAddress?: string
75
+ fromName?: string
76
+ replyTo?: string
77
+ }
78
+
79
+ // ─── Plugin configuration ────────────────────────────────
80
+
81
+ export interface SupportPluginConfig {
82
+ /** Enable/disable individual features (all enabled by default) */
83
+ features?: SupportFeatures
84
+
85
+ /** AI provider configuration */
86
+ ai?: AIProviderConfig
87
+
88
+ /** Email configuration for ticket notifications */
89
+ email?: EmailConfig
90
+
91
+ /** Locale: 'fr' or 'en' (default: 'fr') */
92
+ locale?: 'fr' | 'en'
93
+
94
+ /** Nav group label in Payload admin sidebar */
95
+ navGroup?: string
96
+
97
+ /** Base path for admin views (default: '/support') */
98
+ basePath?: string
99
+
100
+ /** User collection slug for agent relationships (default: 'users') */
101
+ userCollectionSlug?: string
102
+
103
+ /**
104
+ * Restrict Google OAuth auto-registration to specific email domains.
105
+ * When set and non-empty, only emails matching one of these domains can
106
+ * create an account via OAuth. Existing accounts are unaffected.
107
+ * Example: ['acme.com', 'partner.org']
108
+ */
109
+ allowedEmailDomains?: string[]
110
+
111
+ /** Skip injecting collections (use your own custom collections) */
112
+ skipCollections?: boolean
113
+
114
+ /** Skip injecting admin views (use your own custom views) */
115
+ skipViews?: boolean
116
+
117
+ /** Skip injecting endpoints (use your own custom API routes) */
118
+ skipEndpoints?: boolean
119
+
120
+ /** Collection slug overrides */
121
+ collectionSlugs?: {
122
+ tickets?: string
123
+ ticketMessages?: string
124
+ supportClients?: string
125
+ timeEntries?: string
126
+ cannedResponses?: string
127
+ ticketActivityLog?: string
128
+ satisfactionSurveys?: string
129
+ knowledgeBase?: string
130
+ chatMessages?: string
131
+ pendingEmails?: string
132
+ emailLogs?: string
133
+ authLogs?: string
134
+ webhookEndpoints?: string
135
+ slaPolicies?: string
136
+ macros?: string
137
+ ticketStatuses?: string
138
+ }
139
+
140
+ /** Admin notification collection slug (default: 'admin-notifications') */
141
+ notificationSlug?: string
142
+
143
+ /** Custom component path for ticket conversation UI field */
144
+ conversationComponent?: string
145
+
146
+ /** Project collection slug — adds a project relationship to tickets (optional) */
147
+ projectCollectionSlug?: string
148
+
149
+ /** Documents upload collection slug — adds quote/invoice upload fields to tickets (optional) */
150
+ documentsCollectionSlug?: string
151
+ }
152
+
153
+ // ─── Ticket data types ───────────────────────────────────
154
+
155
+ export interface TicketData {
156
+ id: number | string
157
+ ticketNumber: string
158
+ subject: string
159
+ status: string
160
+ priority: string
161
+ category?: string
162
+ client?: number | string
163
+ assignedTo?: number | string
164
+ totalTimeMinutes?: number
165
+ createdAt: string
166
+ updatedAt: string
167
+ }
168
+
169
+ export interface MessageData {
170
+ id: number | string
171
+ ticket: number | string
172
+ body: string
173
+ bodyHtml?: string
174
+ authorType: 'admin' | 'client' | 'email'
175
+ isInternal?: boolean
176
+ attachments?: Array<{ file: number | string }>
177
+ createdAt: string
178
+ }
179
+
180
+ export interface TimeEntryData {
181
+ id: number | string
182
+ ticket: number | string
183
+ minutes: number
184
+ description?: string
185
+ date: string
186
+ }
187
+
188
+ export interface ClientData {
189
+ id: number | string
190
+ email: string
191
+ firstName: string
192
+ lastName: string
193
+ company?: string
194
+ phone?: string
195
+ }
196
+
197
+ export interface CannedResponseData {
198
+ id: number | string
199
+ title: string
200
+ body: string
201
+ category?: string
202
+ }
203
+
204
+ export interface ActivityEntryData {
205
+ id: number | string
206
+ ticket: number | string
207
+ action: string
208
+ field?: string
209
+ oldValue?: string
210
+ newValue?: string
211
+ actorType: 'admin' | 'client' | 'system'
212
+ actorEmail?: string
213
+ createdAt: string
214
+ }
215
+
216
+ export interface SatisfactionSurveyData {
217
+ id: number | string
218
+ ticket: number | string
219
+ client: number | string
220
+ rating: number
221
+ comment?: string
222
+ }
223
+
224
+ // ─── Default feature values ──────────────────────────────
225
+
226
+ export const DEFAULT_FEATURES: Required<SupportFeatures> = {
227
+ timeTracking: true,
228
+ ai: true,
229
+ satisfaction: true,
230
+ chat: true,
231
+ emailTracking: true,
232
+ canned: true,
233
+ merge: true,
234
+ snooze: true,
235
+ externalMessages: true,
236
+ clientHistory: true,
237
+ activityLog: true,
238
+ splitTicket: true,
239
+ scheduledReplies: true,
240
+ autoClose: true,
241
+ autoCloseDays: 7,
242
+ roundRobin: false,
243
+ sla: true,
244
+ webhooks: true,
245
+ macros: true,
246
+ customStatuses: false,
247
+ collisionDetection: true,
248
+ signatures: true,
249
+ chatbot: true,
250
+ bulkActions: true,
251
+ commandPalette: true,
252
+ knowledgeBase: true,
253
+ pendingEmails: true,
254
+ authLogs: true,
255
+ }
@@ -0,0 +1,38 @@
1
+ import type { Payload } from 'payload'
2
+
3
+ /**
4
+ * Helper to create an admin notification.
5
+ * Can be called from any hook or endpoint.
6
+ *
7
+ * @param payload - Payload instance
8
+ * @param data - Notification data
9
+ * @param collectionSlug - Override collection slug (default: 'admin-notifications')
10
+ */
11
+ export async function createAdminNotification(
12
+ payload: Payload,
13
+ data: {
14
+ title: string
15
+ message?: string
16
+ type: 'info' | 'new_ticket' | 'client_message' | 'quote_request' | 'urgent_ticket' | 'post_published' | 'satisfaction' | 'sla_alert'
17
+ link?: string
18
+ recipient?: number | string
19
+ },
20
+ collectionSlug = 'admin-notifications',
21
+ ): Promise<void> {
22
+ try {
23
+ await payload.create({
24
+ collection: collectionSlug as any,
25
+ data: {
26
+ title: data.title,
27
+ message: data.message,
28
+ type: data.type,
29
+ link: data.link,
30
+ recipient: data.recipient,
31
+ read: false,
32
+ },
33
+ overrideAccess: true,
34
+ })
35
+ } catch (err) {
36
+ console.error('[notification] Failed to create:', err)
37
+ }
38
+ }
@@ -0,0 +1,46 @@
1
+ import type { CollectionSlugs } from './slugs'
2
+
3
+ /**
4
+ * Custom error class for authentication/authorization failures.
5
+ * Carries an HTTP status code for consistent API responses.
6
+ */
7
+ export class AuthError extends Error {
8
+ public readonly statusCode: number
9
+
10
+ constructor(message: string, statusCode: number) {
11
+ super(message)
12
+ this.name = 'AuthError'
13
+ this.statusCode = statusCode
14
+ }
15
+ }
16
+
17
+ /**
18
+ * Asserts the request comes from an authenticated admin user.
19
+ * Throws AuthError(401) if unauthenticated, AuthError(403) if not an admin.
20
+ * Uses TypeScript assertion signature so `req.user` is narrowed to non-null after call.
21
+ */
22
+ export function requireAdmin(req: { user?: any }, slugs: CollectionSlugs): asserts req is { user: NonNullable<typeof req.user> } {
23
+ if (!req.user) throw new AuthError('Authentication required', 401)
24
+ if (req.user.collection !== slugs.users) throw new AuthError('Admin access required', 403)
25
+ }
26
+
27
+ /**
28
+ * Asserts the request comes from an authenticated support client.
29
+ * Throws AuthError(401) if unauthenticated, AuthError(403) if not a client.
30
+ * Uses TypeScript assertion signature so `req.user` is narrowed to non-null after call.
31
+ */
32
+ export function requireClient(req: { user?: any }, slugs: CollectionSlugs): asserts req is { user: NonNullable<typeof req.user> } {
33
+ if (!req.user) throw new AuthError('Authentication required', 401)
34
+ if (req.user.collection !== slugs.supportClients) throw new AuthError('Client access required', 403)
35
+ }
36
+
37
+ /**
38
+ * Handles AuthError in catch blocks, returning appropriate JSON responses.
39
+ * Returns null if the error is not an AuthError (caller should handle it).
40
+ */
41
+ export function handleAuthError(error: unknown): Response | null {
42
+ if (error instanceof AuthError) {
43
+ return Response.json({ error: error.message }, { status: error.statusCode })
44
+ }
45
+ return null
46
+ }
@@ -0,0 +1,343 @@
1
+ /**
2
+ * Professional B2B email template system
3
+ * Configurable branding, colors, and layout
4
+ * Tone: Professional, responsive email design
5
+ */
6
+
7
+ // ─── Configuration ───────────────────────────────────────────────────
8
+
9
+ export interface EmailTemplateConfig {
10
+ brandName?: string
11
+ brandColor?: string // primary accent color (hex)
12
+ secondaryColor?: string // secondary accent color (hex)
13
+ accentColor?: string // tertiary accent color (hex)
14
+ logoUrl?: string
15
+ supportEmail?: string
16
+ websiteUrl?: string
17
+ phone?: string
18
+ location?: string
19
+ /** Short brand initials shown in header badge (e.g. "CW", "AB") */
20
+ brandInitials?: string
21
+ }
22
+
23
+ const DEFAULT_CONFIG: Required<EmailTemplateConfig> = {
24
+ brandName: 'Support',
25
+ brandColor: '#00E5FF',
26
+ secondaryColor: '#FFD600',
27
+ accentColor: '#FF8A00',
28
+ logoUrl: '',
29
+ supportEmail: process.env.SUPPORT_EMAIL || '',
30
+ websiteUrl: process.env.NEXT_PUBLIC_SERVER_URL || '',
31
+ phone: '',
32
+ location: '',
33
+ brandInitials: '',
34
+ }
35
+
36
+ function resolveConfig(config?: EmailTemplateConfig): Required<EmailTemplateConfig> {
37
+ return { ...DEFAULT_CONFIG, ...config }
38
+ }
39
+
40
+ // ─── Brand-agnostic utilities ────────────────────────────────────────
41
+
42
+ export function escapeHtml(str: string): string {
43
+ return str
44
+ .replace(/&/g, '&amp;')
45
+ .replace(/</g, '&lt;')
46
+ .replace(/>/g, '&gt;')
47
+ .replace(/"/g, '&quot;')
48
+ .replace(/'/g, '&#039;')
49
+ }
50
+
51
+ /**
52
+ * Tracking pixel for email open detection
53
+ * Inserts a 1x1 transparent GIF that triggers /api/support/track-open
54
+ */
55
+ export function emailTrackingPixel(ticketId: number | string, messageId?: number | string, baseUrl?: string): string {
56
+ const url = baseUrl || process.env.NEXT_PUBLIC_SERVER_URL || ''
57
+ const params = `t=${ticketId}${messageId ? `&m=${messageId}` : ''}`
58
+ return `<img src="${url}/api/support/track-open?${params}" width="1" height="1" alt="" style="display:block;width:1px;height:1px;border:0;" />`
59
+ }
60
+
61
+ /**
62
+ * Convert WYSIWYG HTML to email-safe HTML with inline styles.
63
+ * Handles blockquotes, images, links, lists, and paragraphs.
64
+ */
65
+ export function emailRichContent(html: string, config?: EmailTemplateConfig): string {
66
+ const c = resolveConfig(config)
67
+ const baseUrl = c.websiteUrl
68
+
69
+ // Helper: replace tag with inline-styled version (strips existing style)
70
+ function styleTag(input: string, tag: string, style: string): string {
71
+ const regex = new RegExp(`<${tag}(\\s[^>]*)?>`, 'gi')
72
+ return input.replace(regex, (_match, attrs) => {
73
+ const cleanAttrs = (attrs || '').replace(/\s*style="[^"]*"/g, '')
74
+ return `<${tag}${cleanAttrs} style="${style}">`
75
+ })
76
+ }
77
+
78
+ let result = html
79
+ // Make relative image URLs absolute
80
+ .replace(/src="\/([^"]+)"/g, `src="${baseUrl}/$1"`)
81
+
82
+ // Apply inline styles to elements
83
+ result = styleTag(result, 'blockquote', `border-left: 4px solid ${c.brandColor}; margin: 16px 0; padding: 12px 20px; background: #f0f9fa; border-radius: 0 8px 8px 0;`)
84
+ result = styleTag(result, 'img', 'max-width: 100%; height: auto; border-radius: 8px; margin: 12px 0; display: block;')
85
+ result = styleTag(result, 'a', `color: ${c.brandColor}; text-decoration: underline; font-weight: 600;`)
86
+ result = styleTag(result, 'ul', 'margin: 8px 0; padding-left: 24px;')
87
+ result = styleTag(result, 'ol', 'margin: 8px 0; padding-left: 24px;')
88
+ result = styleTag(result, 'li', 'margin: 4px 0; line-height: 1.6;')
89
+ result = styleTag(result, 'p', 'margin: 0 0 12px 0; line-height: 1.75; font-size: 15px; color: #1f2937;')
90
+
91
+ // Strip dangerous content
92
+ result = result
93
+ .replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
94
+ .replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, '')
95
+ .replace(/\bon\w+\s*=\s*"[^"]*"/gi, '')
96
+ .replace(/\bon\w+\s*=\s*'[^']*'/gi, '')
97
+ .replace(/javascript:/gi, '')
98
+
99
+ return `<div style="font-size: 15px; line-height: 1.75; color: #1f2937;">${result}</div>`
100
+ }
101
+
102
+ // ─── Configurable email components ───────────────────────────────────
103
+
104
+ type ButtonColor = 'primary' | 'secondary' | 'dark'
105
+
106
+ function getButtonColors(config: Required<EmailTemplateConfig>): Record<ButtonColor, { bg: string; text: string; border: string }> {
107
+ return {
108
+ primary: { bg: config.brandColor, text: '#000000', border: '#000000' },
109
+ secondary: { bg: config.secondaryColor, text: '#000000', border: '#000000' },
110
+ dark: { bg: '#000000', text: '#FFFFFF', border: '#000000' },
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Generate a CTA button for emails
116
+ */
117
+ export function emailButton(text: string, url: string, color: ButtonColor = 'primary', config?: EmailTemplateConfig): string {
118
+ const c = resolveConfig(config)
119
+ const colors = getButtonColors(c)
120
+ const bc = colors[color]
121
+ return `
122
+ <div style="text-align: center; margin: 32px 0;">
123
+ <a href="${url}" style="display: inline-block; padding: 16px 40px; background: ${bc.bg}; color: ${bc.text}; font-weight: 800; font-size: 15px; text-decoration: none; border-radius: 10px; border: 2px solid ${bc.border}; letter-spacing: 0.02em;">
124
+ ${text}
125
+ </a>
126
+ </div>
127
+ `
128
+ }
129
+
130
+ /**
131
+ * Quote block for message previews
132
+ */
133
+ export function emailQuote(content: string, borderColor?: string, config?: EmailTemplateConfig): string {
134
+ const c = resolveConfig(config)
135
+ const color = borderColor || c.brandColor
136
+ return `
137
+ <div style="margin: 24px 0; padding: 20px 24px; background: #f8f9fa; border-left: 4px solid ${color}; border-radius: 0 8px 8px 0;">
138
+ <p style="margin: 0; font-size: 15px; line-height: 1.75; color: #333333; white-space: pre-wrap;">${escapeHtml(content)}</p>
139
+ </div>
140
+ `
141
+ }
142
+
143
+ /**
144
+ * Info row (label: value) for structured data in emails
145
+ */
146
+ export function emailInfoRow(label: string, value: string): string {
147
+ return `
148
+ <tr>
149
+ <td style="padding: 8px 0; font-size: 14px; font-weight: 700; color: #333333; width: 150px; vertical-align: top; text-transform: uppercase; letter-spacing: 0.03em; font-size: 12px;">${escapeHtml(label)}</td>
150
+ <td style="padding: 8px 0; font-size: 15px; color: #1f2937; line-height: 1.5;">${value}</td>
151
+ </tr>
152
+ `
153
+ }
154
+
155
+ /**
156
+ * Paragraph helper with professional styling
157
+ */
158
+ export function emailParagraph(text: string): string {
159
+ return `<p style="margin: 0 0 18px 0; font-size: 15px; line-height: 1.75; color: #1f2937;">${text}</p>`
160
+ }
161
+
162
+ /**
163
+ * Generate the professional footer
164
+ */
165
+ function emailFooter(config: Required<EmailTemplateConfig>): string {
166
+ const logoHtml = config.logoUrl
167
+ ? `<a href="${config.websiteUrl}">
168
+ <img src="${config.logoUrl}" alt="${escapeHtml(config.brandName)}" width="100" height="47" style="display: block; border: 0;" />
169
+ </a>`
170
+ : `<a href="${config.websiteUrl}" style="font-size: 18px; font-weight: 900; color: #000000; text-decoration: none;">${escapeHtml(config.brandName)}</a>`
171
+
172
+ const contactParts: string[] = []
173
+ if (config.supportEmail) {
174
+ contactParts.push(`<a href="mailto:${config.supportEmail}" style="color: #555555; text-decoration: none;">${config.supportEmail}</a>`)
175
+ }
176
+ if (config.phone) {
177
+ contactParts.push(`<a href="tel:${config.phone.replace(/\s/g, '')}" style="color: #555555; text-decoration: none;">${config.phone}</a>`)
178
+ }
179
+
180
+ const locationParts: string[] = []
181
+ if (config.websiteUrl) {
182
+ const displayUrl = config.websiteUrl.replace(/^https?:\/\//, '')
183
+ locationParts.push(`<a href="${config.websiteUrl}" style="color: ${config.brandColor}; text-decoration: none; font-weight: 600;">${displayUrl}</a>`)
184
+ }
185
+ if (config.location) {
186
+ locationParts.push(config.location)
187
+ }
188
+
189
+ const unsubscribeHtml = config.supportEmail
190
+ ? `<p style="margin: 8px 0 0 0; font-size: 11px; color: #aaaaaa; line-height: 1.4;">
191
+ <a href="mailto:${config.supportEmail}?subject=Unsubscribe" style="color: #aaaaaa; text-decoration: underline;">Se d&eacute;sinscrire</a>
192
+ </p>`
193
+ : ''
194
+
195
+ return `
196
+ <!-- Spacer -->
197
+ <div style="height: 24px;"></div>
198
+
199
+ <!-- Tricolor separator -->
200
+ <table cellpadding="0" cellspacing="0" border="0" width="100%" style="margin-bottom: 24px;">
201
+ <tr>
202
+ <td width="50%" height="3" bgcolor="${config.brandColor}" style="font-size:1px;line-height:1px;">&nbsp;</td>
203
+ <td width="25%" height="3" bgcolor="${config.secondaryColor}" style="font-size:1px;line-height:1px;">&nbsp;</td>
204
+ <td width="25%" height="3" bgcolor="${config.accentColor}" style="font-size:1px;line-height:1px;">&nbsp;</td>
205
+ </tr>
206
+ </table>
207
+
208
+ <!-- Footer -->
209
+ <table cellpadding="0" cellspacing="0" border="0" width="100%">
210
+ <tr>
211
+ <td width="120" valign="top" style="padding-right: 16px;">
212
+ ${logoHtml}
213
+ </td>
214
+ <td valign="top">
215
+ <p style="margin: 0; font-size: 14px; font-weight: 800; color: #000000; letter-spacing: 0.01em;">${escapeHtml(config.brandName)}</p>
216
+ ${contactParts.length > 0 ? `<p style="margin: 4px 0 0 0; font-size: 13px; color: #555555; line-height: 1.5;">${contactParts.join(' &nbsp;&middot;&nbsp; ')}</p>` : ''}
217
+ ${locationParts.length > 0 ? `<p style="margin: 4px 0 0 0; font-size: 12px; color: #888888; line-height: 1.4;">${locationParts.join(' &nbsp;&middot;&nbsp; ')}</p>` : ''}
218
+ ${unsubscribeHtml}
219
+ </td>
220
+ </tr>
221
+ </table>
222
+ `
223
+ }
224
+
225
+ interface EmailOptions {
226
+ /** Header background color variant */
227
+ headerColor?: 'primary' | 'secondary'
228
+ /** Preheader text (hidden preview in email clients) */
229
+ preheader?: string
230
+ }
231
+
232
+ /**
233
+ * Wrap email content in the professional template
234
+ */
235
+ export function emailWrapper(title: string, body: string, options: EmailOptions = {}, config?: EmailTemplateConfig): string {
236
+ const c = resolveConfig(config)
237
+ const { headerColor = 'primary', preheader } = options
238
+ const headerBg = headerColor === 'secondary' ? c.secondaryColor : c.brandColor
239
+
240
+ const badgeHtml = c.brandInitials
241
+ ? `<td width="48" align="right" valign="middle">
242
+ <div style="width: 36px; height: 36px; border-radius: 8px; background: #000; display: inline-block; text-align: center; line-height: 36px;">
243
+ <span style="color: #fff; font-weight: 900; font-size: 14px; letter-spacing: 0.05em;">${escapeHtml(c.brandInitials)}</span>
244
+ </div>
245
+ </td>`
246
+ : ''
247
+
248
+ return `<!DOCTYPE html>
249
+ <html lang="fr">
250
+ <head>
251
+ <meta charset="utf-8" />
252
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
253
+ <title>${escapeHtml(title)}</title>
254
+ <!--[if mso]>
255
+ <style type="text/css">
256
+ body, table, td { font-family: Arial, Helvetica, sans-serif; }
257
+ </style>
258
+ <![endif]-->
259
+ </head>
260
+ <body style="margin: 0; padding: 0; background: #f0f0f0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;">
261
+ ${preheader ? `<div style="display: none; max-height: 0; overflow: hidden; mso-hide: all;">${escapeHtml(preheader)}</div>` : ''}
262
+
263
+ <!-- Outer wrapper -->
264
+ <table cellpadding="0" cellspacing="0" border="0" width="100%" style="background: #f0f0f0; padding: 40px 16px;">
265
+ <tr>
266
+ <td align="center">
267
+ <!-- Main container — wider (660px) -->
268
+ <table cellpadding="0" cellspacing="0" border="0" width="660" style="max-width: 660px; width: 100%;">
269
+
270
+ <!-- Header -->
271
+ <tr>
272
+ <td style="background: ${headerBg}; padding: 32px 40px; border-radius: 12px 12px 0 0; border: 2px solid #000000; border-bottom: none;">
273
+ <table cellpadding="0" cellspacing="0" border="0" width="100%">
274
+ <tr>
275
+ <td>
276
+ <h1 style="margin: 0; color: #000000; font-size: 22px; font-weight: 800; line-height: 1.3; letter-spacing: -0.01em;">
277
+ ${escapeHtml(title)}
278
+ </h1>
279
+ </td>
280
+ ${badgeHtml}
281
+ </tr>
282
+ </table>
283
+ </td>
284
+ </tr>
285
+
286
+ <!-- Body -->
287
+ <tr>
288
+ <td style="background: #ffffff; padding: 40px; border: 2px solid #000000; border-top: none; border-radius: 0 0 12px 12px;">
289
+ ${body}
290
+ ${emailFooter(c)}
291
+ </td>
292
+ </tr>
293
+
294
+ </table>
295
+ </td>
296
+ </tr>
297
+ </table>
298
+ </body>
299
+ </html>`
300
+ }
301
+
302
+ // ─── Factory ─────────────────────────────────────────────────────────
303
+
304
+ export interface EmailTemplateFactory {
305
+ emailButton: (text: string, url: string, color?: ButtonColor) => string
306
+ emailWrapper: (title: string, body: string, options?: EmailOptions) => string
307
+ emailQuote: (content: string, borderColor?: string) => string
308
+ emailInfoRow: (label: string, value: string) => string
309
+ emailParagraph: (text: string) => string
310
+ emailTrackingPixel: (ticketId: number | string, messageId?: number | string) => string
311
+ emailRichContent: (html: string) => string
312
+ escapeHtml: (str: string) => string
313
+ }
314
+
315
+ /**
316
+ * Create a pre-configured email template factory.
317
+ * All returned functions use the provided config automatically.
318
+ *
319
+ * @example
320
+ * const email = createEmailTemplateFactory({
321
+ * brandName: 'MyBrand',
322
+ * brandColor: '#FF5733',
323
+ * supportEmail: 'help@mybrand.com',
324
+ * websiteUrl: 'https://mybrand.com',
325
+ * logoUrl: 'https://mybrand.com/logo.png',
326
+ * brandInitials: 'MB',
327
+ * })
328
+ *
329
+ * const html = email.emailWrapper('Welcome!', email.emailParagraph('Hello world.'))
330
+ */
331
+ export function createEmailTemplateFactory(config: EmailTemplateConfig): EmailTemplateFactory {
332
+ const c = resolveConfig(config)
333
+ return {
334
+ emailButton: (text, url, color = 'primary') => emailButton(text, url, color, c),
335
+ emailWrapper: (title, body, options = {}) => emailWrapper(title, body, options, c),
336
+ emailQuote: (content, borderColor?) => emailQuote(content, borderColor, c),
337
+ emailInfoRow,
338
+ emailParagraph,
339
+ emailTrackingPixel: (ticketId, messageId?) => emailTrackingPixel(ticketId, messageId, c.websiteUrl),
340
+ emailRichContent: (html) => emailRichContent(html, c),
341
+ escapeHtml,
342
+ }
343
+ }