@consilioweb/payload-support 0.8.2 → 0.9.5

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 (50) hide show
  1. package/dist/components/RichTextEditor/index.cjs +233 -0
  2. package/dist/components/RichTextEditor/index.js +232 -0
  3. package/dist/components/TicketConversation/components/CodeBlock.cjs +24 -7
  4. package/dist/components/TicketConversation/components/CodeBlock.js +23 -9
  5. package/dist/index.cjs +19 -1
  6. package/dist/index.js +19 -1
  7. package/dist/views/BillingView/client.cjs +260 -103
  8. package/dist/views/BillingView/client.js +259 -103
  9. package/dist/views/ChatView/client.cjs +184 -137
  10. package/dist/views/ChatView/client.js +180 -137
  11. package/dist/views/CrmView/client.cjs +270 -122
  12. package/dist/views/CrmView/client.js +266 -122
  13. package/dist/views/EmailTrackingView/client.cjs +80 -69
  14. package/dist/views/EmailTrackingView/client.js +80 -70
  15. package/dist/views/ImportConversationView/client.cjs +127 -94
  16. package/dist/views/ImportConversationView/client.js +123 -94
  17. package/dist/views/LogsView/client.cjs +56 -58
  18. package/dist/views/LogsView/client.js +52 -58
  19. package/dist/views/NewTicketView/client.cjs +39 -55
  20. package/dist/views/NewTicketView/client.js +38 -55
  21. package/dist/views/PendingEmailsView/client.cjs +399 -102
  22. package/dist/views/PendingEmailsView/client.js +396 -103
  23. package/dist/views/SupportDashboardView/client.cjs +276 -137
  24. package/dist/views/SupportDashboardView/client.js +275 -137
  25. package/dist/views/TicketDetailView/client.cjs +487 -204
  26. package/dist/views/TicketDetailView/client.js +486 -204
  27. package/dist/views/TicketInboxView/client.cjs +62 -65
  28. package/dist/views/TicketInboxView/client.js +62 -66
  29. package/dist/views/TicketingSettingsView/client.cjs +10 -8
  30. package/dist/views/TicketingSettingsView/client.js +10 -8
  31. package/dist/views/TimeDashboardView/client.cjs +70 -59
  32. package/dist/views/TimeDashboardView/client.js +69 -59
  33. package/package.json +7 -3
  34. package/src/components/RichTextEditor/index.tsx +261 -0
  35. package/src/components/TicketConversation/components/CodeBlock.tsx +53 -14
  36. package/src/plugin.ts +2 -0
  37. package/src/utils/emailTemplate.ts +37 -0
  38. package/src/views/BillingView/client.tsx +362 -69
  39. package/src/views/ChatView/client.tsx +225 -140
  40. package/src/views/CrmView/client.tsx +447 -189
  41. package/src/views/EmailTrackingView/client.tsx +111 -71
  42. package/src/views/ImportConversationView/client.tsx +255 -70
  43. package/src/views/LogsView/client.tsx +85 -50
  44. package/src/views/NewTicketView/client.tsx +37 -53
  45. package/src/views/PendingEmailsView/client.tsx +512 -92
  46. package/src/views/SupportDashboardView/client.tsx +294 -134
  47. package/src/views/TicketDetailView/client.tsx +486 -213
  48. package/src/views/TicketInboxView/client.tsx +52 -61
  49. package/src/views/TicketingSettingsView/client.tsx +10 -9
  50. package/src/views/TimeDashboardView/client.tsx +184 -69
@@ -0,0 +1,261 @@
1
+ 'use client'
2
+
3
+ import React, { useRef, useCallback, useState, useImperativeHandle, forwardRef, useEffect } from 'react'
4
+
5
+ export interface RichTextEditorHandle {
6
+ clear: () => void
7
+ setContent: (html: string) => void
8
+ getHtml: () => string
9
+ getPlainText: () => string
10
+ focus: () => void
11
+ }
12
+
13
+ interface Props {
14
+ initialValue?: string
15
+ onChange: (html: string, plainText: string) => void
16
+ placeholder?: string
17
+ minHeight?: number
18
+ onFileUpload?: (file: File) => Promise<string | null>
19
+ borderColor?: string
20
+ focusBorderColor?: string
21
+ }
22
+
23
+ export const RichTextEditor = forwardRef<RichTextEditorHandle, Props>(function RichTextEditor(
24
+ {
25
+ initialValue = '',
26
+ onChange,
27
+ placeholder = 'Écrivez votre message...',
28
+ minHeight = 150,
29
+ onFileUpload,
30
+ borderColor = '#000',
31
+ focusBorderColor = '#00E5FF',
32
+ },
33
+ ref,
34
+ ) {
35
+ const editorRef = useRef<HTMLDivElement>(null)
36
+ const fileInputRef = useRef<HTMLInputElement>(null)
37
+ const [focused, setFocused] = useState(false)
38
+ const [isEmpty, setIsEmpty] = useState(!initialValue)
39
+
40
+ // Set initial content via ref (avoids React reconciliation issues with contentEditable)
41
+ useEffect(() => {
42
+ if (editorRef.current && initialValue) {
43
+ editorRef.current.innerHTML = initialValue
44
+ const text = editorRef.current.innerText?.trim() || ''
45
+ setIsEmpty(!text && !editorRef.current.querySelector('img'))
46
+ }
47
+ // eslint-disable-next-line react-hooks/exhaustive-deps
48
+ }, [])
49
+
50
+ const emitChange = useCallback(() => {
51
+ if (!editorRef.current) return
52
+ const html = editorRef.current.innerHTML
53
+ const text = editorRef.current.innerText?.trim() || ''
54
+ const empty = !text && !editorRef.current.querySelector('img')
55
+ setIsEmpty(empty)
56
+ onChange(empty ? '' : html, text)
57
+ }, [onChange])
58
+
59
+ const exec = useCallback((command: string, value?: string) => {
60
+ document.execCommand(command, false, value)
61
+ editorRef.current?.focus()
62
+ setTimeout(emitChange, 0)
63
+ }, [emitChange])
64
+
65
+ useImperativeHandle(ref, () => ({
66
+ clear: () => {
67
+ if (editorRef.current) {
68
+ editorRef.current.innerHTML = ''
69
+ setIsEmpty(true)
70
+ onChange('', '')
71
+ }
72
+ },
73
+ setContent: (html: string) => {
74
+ if (editorRef.current) {
75
+ editorRef.current.innerHTML = html
76
+ const text = editorRef.current.innerText?.trim() || ''
77
+ setIsEmpty(!text && !editorRef.current.querySelector('img'))
78
+ onChange(html, text)
79
+ }
80
+ },
81
+ getHtml: () => editorRef.current?.innerHTML || '',
82
+ getPlainText: () => editorRef.current?.innerText?.trim() || '',
83
+ focus: () => editorRef.current?.focus(),
84
+ }))
85
+
86
+ const handleInsertLink = useCallback(() => {
87
+ const sel = window.getSelection()
88
+ if (!sel || sel.isCollapsed) {
89
+ const url = prompt('URL du lien :')
90
+ if (!url) return
91
+ const text = prompt('Texte du lien :', url) || url
92
+ const safeUrl = url.replace(/"/g, '&quot;')
93
+ const safeText = text.replace(/</g, '&lt;').replace(/>/g, '&gt;')
94
+ document.execCommand('insertHTML', false, `<a href="${safeUrl}" target="_blank" rel="noopener noreferrer">${safeText}</a>`)
95
+ editorRef.current?.focus()
96
+ setTimeout(emitChange, 0)
97
+ } else {
98
+ const url = prompt('URL du lien :')
99
+ if (url) exec('createLink', url)
100
+ }
101
+ }, [exec, emitChange])
102
+
103
+ const handleImageClick = useCallback(() => {
104
+ if (onFileUpload) {
105
+ fileInputRef.current?.click()
106
+ } else {
107
+ const url = prompt('URL de l\'image :')
108
+ if (url) {
109
+ const safeUrl = url.replace(/"/g, '&quot;')
110
+ document.execCommand('insertHTML', false, `<img src="${safeUrl}" alt="Image" style="max-width:100%;height:auto;border-radius:8px;margin:8px 0;" />`)
111
+ editorRef.current?.focus()
112
+ setTimeout(emitChange, 0)
113
+ }
114
+ }
115
+ }, [onFileUpload, emitChange])
116
+
117
+ const handleFileChange = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
118
+ if (!e.target.files || !onFileUpload) return
119
+ for (const file of Array.from(e.target.files)) {
120
+ if (!file.type.startsWith('image/')) continue
121
+ const url = await onFileUpload(file)
122
+ if (url) {
123
+ const safeName = file.name.replace(/"/g, '&quot;')
124
+ document.execCommand('insertHTML', false, `<img src="${url}" alt="${safeName}" style="max-width:100%;height:auto;border-radius:8px;margin:8px 0;" />`)
125
+ editorRef.current?.focus()
126
+ setTimeout(emitChange, 0)
127
+ }
128
+ }
129
+ if (fileInputRef.current) fileInputRef.current.value = ''
130
+ }, [onFileUpload, emitChange])
131
+
132
+ const handlePaste = useCallback(async (e: React.ClipboardEvent) => {
133
+ if (!onFileUpload) return
134
+ const items = e.clipboardData?.items
135
+ if (!items) return
136
+ for (const item of Array.from(items)) {
137
+ if (item.type.startsWith('image/')) {
138
+ e.preventDefault()
139
+ const file = item.getAsFile()
140
+ if (file) {
141
+ const url = await onFileUpload(file)
142
+ if (url) {
143
+ document.execCommand('insertHTML', false, `<img src="${url}" alt="Image collée" style="max-width:100%;height:auto;border-radius:8px;margin:8px 0;" />`)
144
+ editorRef.current?.focus()
145
+ setTimeout(emitChange, 0)
146
+ }
147
+ }
148
+ return
149
+ }
150
+ }
151
+ }, [onFileUpload, emitChange])
152
+
153
+ const btn: React.CSSProperties = {
154
+ border: 'none',
155
+ background: 'transparent',
156
+ cursor: 'pointer',
157
+ padding: '8px 10px',
158
+ fontSize: '13px',
159
+ fontWeight: 700,
160
+ color: '#555',
161
+ borderRadius: '5px',
162
+ lineHeight: 1,
163
+ minHeight: '44px',
164
+ minWidth: '44px',
165
+ display: 'inline-flex',
166
+ alignItems: 'center',
167
+ justifyContent: 'center',
168
+ }
169
+
170
+ const sep: React.CSSProperties = {
171
+ width: '1px',
172
+ height: '18px',
173
+ background: '#d1d5db',
174
+ margin: '0 4px',
175
+ alignSelf: 'center',
176
+ }
177
+
178
+ return (
179
+ <div style={{ border: `3px solid ${focused ? focusBorderColor : borderColor}`, borderRadius: '12px', overflow: 'hidden', transition: 'border-color 0.15s', background: '#fff' }}>
180
+ <style>{`
181
+ .rte-toolbar button:hover { background: #e5e7eb !important; }
182
+ .rte-editor blockquote { border-left: 4px solid #00E5FF; margin: 8px 0; padding: 8px 16px; background: #f0f9ff; border-radius: 0 6px 6px 0; }
183
+ .rte-editor img { max-width: 100%; height: auto; border-radius: 8px; margin: 8px 0; }
184
+ .rte-editor a { color: #00838f; text-decoration: underline; }
185
+ .rte-editor ul, .rte-editor ol { margin: 8px 0; padding-left: 24px; }
186
+ .rte-editor li { margin: 2px 0; }
187
+ .rte-editor p { margin: 0 0 6px 0; }
188
+ `}</style>
189
+
190
+ {/* Toolbar */}
191
+ <div className="rte-toolbar" style={{ display: 'flex', alignItems: 'center', gap: '2px', padding: '6px 10px', borderBottom: '2px solid #e5e7eb', backgroundColor: '#f9fafb', flexWrap: 'wrap' }}>
192
+ <button type="button" style={btn} onMouseDown={(e) => { e.preventDefault(); exec('bold') }} title="Gras (Ctrl+B)">
193
+ <strong>B</strong>
194
+ </button>
195
+ <button type="button" style={btn} onMouseDown={(e) => { e.preventDefault(); exec('italic') }} title="Italique (Ctrl+I)">
196
+ <em style={{ fontStyle: 'italic' }}>I</em>
197
+ </button>
198
+ <button type="button" style={btn} onMouseDown={(e) => { e.preventDefault(); exec('underline') }} title="Souligné (Ctrl+U)">
199
+ <span style={{ textDecoration: 'underline' }}>S</span>
200
+ </button>
201
+ <span style={sep} />
202
+ <button type="button" style={{ ...btn, fontSize: '16px' }} onMouseDown={(e) => { e.preventDefault(); exec('formatBlock', 'blockquote') }} title="Citation">
203
+ &ldquo;&rdquo;
204
+ </button>
205
+ <button type="button" style={{ ...btn, fontSize: '12px' }} onMouseDown={(e) => { e.preventDefault(); exec('insertUnorderedList') }} title="Liste à puces">
206
+ &bull; Liste
207
+ </button>
208
+ <button type="button" style={{ ...btn, fontSize: '12px' }} onMouseDown={(e) => { e.preventDefault(); exec('insertOrderedList') }} title="Liste numérotée">
209
+ 1. Liste
210
+ </button>
211
+ <span style={sep} />
212
+ <button type="button" style={btn} onMouseDown={(e) => { e.preventDefault(); handleInsertLink() }} title="Insérer un lien">
213
+ &#128279;
214
+ </button>
215
+ <button type="button" style={btn} onMouseDown={(e) => { e.preventDefault(); handleImageClick() }} title="Insérer une image">
216
+ &#128444;&#65039;
217
+ </button>
218
+ </div>
219
+
220
+ {/* Editor area */}
221
+ <div style={{ position: 'relative' }}>
222
+ {isEmpty && (
223
+ <div style={{ position: 'absolute', top: 0, left: 0, right: 0, padding: '14px 16px', color: '#9ca3af', fontSize: '14px', pointerEvents: 'none', userSelect: 'none' }}>
224
+ {placeholder}
225
+ </div>
226
+ )}
227
+ <div
228
+ ref={editorRef}
229
+ className="rte-editor"
230
+ contentEditable
231
+ suppressContentEditableWarning
232
+ onInput={emitChange}
233
+ onFocus={() => setFocused(true)}
234
+ onBlur={() => { setFocused(false); emitChange() }}
235
+ onPaste={handlePaste}
236
+ style={{
237
+ minHeight: `${minHeight}px`,
238
+ padding: '14px 16px',
239
+ fontSize: '14px',
240
+ lineHeight: 1.6,
241
+ color: '#1f2937',
242
+ outline: 'none',
243
+ overflowY: 'auto',
244
+ }}
245
+ />
246
+ </div>
247
+
248
+ {/* Hidden file input for image upload */}
249
+ {onFileUpload && (
250
+ <input
251
+ ref={fileInputRef}
252
+ type="file"
253
+ accept="image/*"
254
+ multiple
255
+ onChange={handleFileChange}
256
+ style={{ display: 'none' }}
257
+ />
258
+ )}
259
+ </div>
260
+ )
261
+ })
@@ -149,17 +149,21 @@ function SingleCodeBlock({ lang, code }: { lang: string; code: string }) {
149
149
  }
150
150
 
151
151
  /**
152
- * Renders text with fenced code blocks (```lang ... ```) styled per language.
153
- * Non-code text is passed through as-is.
152
+ * Checks if text contains fenced code blocks.
154
153
  */
155
- export function CodeBlockRenderer({ text }: { text: string }) {
156
- // Match ```lang\n...\n``` blocks
154
+ export function hasCodeBlocks(text: string): boolean {
155
+ return /```[\s\S]*?```/.test(text)
156
+ }
157
+
158
+ /**
159
+ * Renders the FULL message text, replacing fenced code blocks with styled components.
160
+ * Use this INSTEAD of rendering msg.body directly when code blocks are present.
161
+ */
162
+ export function MessageWithCodeBlocks({ text, style }: { text: string; style?: React.CSSProperties }) {
157
163
  const parts = text.split(/(```[\s\S]*?```)/g)
158
- const hasCodeBlock = parts.some((p) => p.startsWith('```'))
159
- if (!hasCodeBlock) return null
160
164
 
161
165
  return (
162
- <>
166
+ <div style={style}>
163
167
  {parts.map((part, i) => {
164
168
  if (part.startsWith('```')) {
165
169
  const match = part.match(/^```(\w*)\n?([\s\S]*?)```$/)
@@ -169,18 +173,53 @@ export function CodeBlockRenderer({ text }: { text: string }) {
169
173
  return <SingleCodeBlock key={i} lang={lang} code={code} />
170
174
  }
171
175
  }
172
- return null
176
+ // Render plain text parts
177
+ if (!part) return null
178
+ return <span key={i} style={{ whiteSpace: 'pre-wrap' }}>{part}</span>
173
179
  })}
174
- </>
180
+ </div>
175
181
  )
176
182
  }
177
183
 
178
184
  /**
179
- * For HTML content, parses <pre><code> blocks from the RTE.
180
- * Also handles ```lang blocks that survived as text in HTML.
185
+ * Converts HTML to plain text while preserving line breaks from block-level tags.
181
186
  */
187
+ function htmlToText(html: string): string {
188
+ return html
189
+ // Convert line breaks first (before stripping)
190
+ .replace(/<br\s*\/?>/gi, '\n')
191
+ .replace(/<\/(p|div|li|h[1-6]|tr|pre)>/gi, '\n')
192
+ .replace(/<\/?(ul|ol|table|tbody|thead)[^>]*>/gi, '')
193
+ // Strip remaining tags
194
+ .replace(/<[^>]+>/g, '')
195
+ // Decode common HTML entities
196
+ .replace(/&nbsp;/g, ' ')
197
+ .replace(/&amp;/g, '&')
198
+ .replace(/&lt;/g, '<')
199
+ .replace(/&gt;/g, '>')
200
+ .replace(/&quot;/g, '"')
201
+ .replace(/&#39;/g, "'")
202
+ // Collapse 3+ newlines into 2
203
+ .replace(/\n{3,}/g, '\n\n')
204
+ .trim()
205
+ }
206
+
207
+ /**
208
+ * For HTML content: strips tags (preserving newlines), then renders with code blocks.
209
+ */
210
+ export function MessageWithCodeBlocksHtml({ html, style }: { html: string; style?: React.CSSProperties }) {
211
+ const text = htmlToText(html)
212
+ if (!hasCodeBlocks(text)) return null
213
+ return <MessageWithCodeBlocks text={text} style={style} />
214
+ }
215
+
216
+ // Keep old exports for backwards compat (but they're no longer used in message rendering)
217
+ export function CodeBlockRenderer({ text }: { text: string }) {
218
+ if (!hasCodeBlocks(text)) return null
219
+ return <MessageWithCodeBlocks text={text} />
220
+ }
221
+
182
222
  export function CodeBlockRendererHtml({ html }: { html: string }) {
183
- // Check for fenced code blocks in raw text within HTML
184
- const stripped = html.replace(/<[^>]+>/g, '')
185
- return <CodeBlockRenderer text={stripped} />
223
+ const text = htmlToText(html)
224
+ return <CodeBlockRenderer text={text} />
186
225
  }
package/src/plugin.ts CHANGED
@@ -115,6 +115,8 @@ export function supportPlugin(config?: SupportPluginConfig): Plugin {
115
115
  'support-settings': viewConfig(`${viewsBase}#TicketingSettingsView`, `${bp}/settings`),
116
116
  'support-logs': viewConfig(`${viewsBase}#LogsView`, `${bp}/logs`),
117
117
  'support-crm': viewConfig(`${viewsBase}#CrmView`, `${bp}/crm`),
118
+ 'support-billing': viewConfig(`${viewsBase}#BillingView`, `${bp}/billing`),
119
+ 'support-import': viewConfig(`${viewsBase}#ImportConversationView`, `/import-conversation`),
118
120
  }
119
121
 
120
122
  if (features.chat) {
@@ -75,10 +75,47 @@ export function emailRichContent(html: string, config?: EmailTemplateConfig): st
75
75
  })
76
76
  }
77
77
 
78
+ // Convert markdown fenced code blocks (```lang\ncode\n```) to styled <pre> blocks
79
+ // Email clients don't run JS so we just use a nice layout, no syntax highlighting
80
+ function convertFencedCodeBlocks(input: string): string {
81
+ // Quick check: no backticks, no work to do
82
+ if (!input.includes('```')) return input
83
+
84
+ // Normalize HTML block breaks to newlines so the regex can span paragraphs
85
+ const normalized = input
86
+ .replace(/<br\s*\/?>/gi, '\n')
87
+ .replace(/<\/(p|div)>\s*<(p|div)[^>]*>/gi, '\n')
88
+ .replace(/<(p|div)[^>]*>/gi, '')
89
+ .replace(/<\/(p|div)>/gi, '\n')
90
+
91
+ return normalized.replace(/```(\w*)\n?([\s\S]*?)```/g, (_match, lang, code) => {
92
+ const cleanCode = escapeHtml((code || '').replace(/\n$/, ''))
93
+ const label = lang ? lang.trim() : 'code'
94
+ return `<div style="margin: 16px 0; border: 1px solid #1f2937; border-radius: 8px; overflow: hidden; background: #0f172a;">
95
+ <div style="padding: 6px 14px; background: #1e293b; border-bottom: 1px solid #334155; font-family: 'SF Mono', 'Fira Code', Consolas, monospace; font-size: 11px; font-weight: 700; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.05em;">${escapeHtml(label)}</div>
96
+ <pre style="margin: 0; padding: 14px 16px; overflow-x: auto; background: #0f172a; color: #e2e8f0; font-family: 'SF Mono', 'Fira Code', Consolas, monospace; font-size: 13px; line-height: 1.6; white-space: pre;"><code style="background: transparent; padding: 0; color: inherit; font-family: inherit; font-size: inherit;">${cleanCode}</code></pre>
97
+ </div>`
98
+ })
99
+ // Re-wrap non-code text chunks in paragraphs (split by our <div> blocks)
100
+ .split(/(<div style="margin: 16px 0[\s\S]*?<\/div>)/g)
101
+ .map((chunk) => {
102
+ if (chunk.startsWith('<div style="margin: 16px 0')) return chunk
103
+ // Non-code chunk: wrap text lines in <p>
104
+ return chunk
105
+ .split('\n')
106
+ .map((line) => line.trim() ? `<p>${line}</p>` : '')
107
+ .join('')
108
+ })
109
+ .join('')
110
+ }
111
+
78
112
  let result = html
79
113
  // Make relative image URLs absolute
80
114
  .replace(/src="\/([^"]+)"/g, `src="${baseUrl}/$1"`)
81
115
 
116
+ // Convert fenced code blocks BEFORE other processing (protects code from HTML styling)
117
+ result = convertFencedCodeBlocks(result)
118
+
82
119
  // Apply inline styles to elements
83
120
  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
121
  result = styleTag(result, 'img', 'max-width: 100%; height: auto; border-radius: 8px; margin: 12px 0; display: block;')