@adatechnology/conversations-ui 0.2.1 → 0.3.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/dist/{chunk-DKPXKQGC.js → chunk-HMCOTZAX.js} +18 -1
- package/dist/{chunk-NHQDQJL2.js → chunk-L4OE4ORQ.js} +18 -19
- package/dist/{chunk-WCBDXZ3X.js → chunk-PJNR6UMN.js} +73 -8
- package/dist/flows/index.js +32 -28
- package/dist/index.d.ts +42 -3
- package/dist/index.js +563 -289
- package/dist/preview/index.js +2 -2
- package/package.json +1 -1
- package/src/InteractiveMessage.tsx +13 -13
- package/src/MessageText.tsx +1 -3
- package/src/blockFormattingNesting.test.tsx +47 -0
- package/src/flows/FlowWhatsAppPreview.tsx +40 -42
- package/src/index.ts +3 -0
- package/src/lib/WhatsAppMessagePreview.tsx +32 -0
- package/src/lib/composer-formatting.ts +8 -0
- package/src/lib/whatsapp-formatting.test.tsx +65 -2
- package/src/lib/whatsapp-formatting.tsx +110 -13
- package/src/quickReplies/QuickRepliesWorkspace.tsx +249 -164
- package/src/quickReplies/QuickReplyFormattingToolbar.tsx +62 -0
- package/src/quickReplies/QuickReplyWhatsAppPreview.tsx +57 -0
- package/src/quickReplies/labels.ts +27 -1
- package/src/quickReplies/quickReplyFormatting.test.tsx +336 -0
- package/src/quickReplies/quickReplyFormatting.ts +232 -0
- package/src/quickReplies/useQuickRepliesWorkspace.ts +1 -1
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
import { useEffect, useId, useRef, useState, type KeyboardEvent } from 'react'
|
|
2
|
+
import { Pencil, Plus, Trash2 } from 'lucide-react'
|
|
2
3
|
import { cn } from '../lib/cn'
|
|
3
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
useQuickRepliesWorkspace,
|
|
6
|
+
insertAtCursor,
|
|
7
|
+
BODY_MAX_LENGTH,
|
|
8
|
+
type QuickRepliesWorkspaceApi,
|
|
9
|
+
} from './useQuickRepliesWorkspace'
|
|
10
|
+
import { canExecuteFormattingCommand } from '../lib/composer-formatting'
|
|
11
|
+
import {
|
|
12
|
+
changedRange,
|
|
13
|
+
computeFormattingEdit,
|
|
14
|
+
formattingActionForShortcut,
|
|
15
|
+
type QuickReplyFormattingAction,
|
|
16
|
+
} from './quickReplyFormatting'
|
|
17
|
+
import { QuickReplyFormattingToolbar } from './QuickReplyFormattingToolbar'
|
|
18
|
+
import { QuickReplyWhatsAppPreview } from './QuickReplyWhatsAppPreview'
|
|
4
19
|
import { AttachmentsFormSection } from './AttachmentsFormSection'
|
|
5
20
|
import { DEFAULT_QUICK_REPLIES_WORKSPACE_LABELS, type QuickRepliesWorkspaceLabels } from './labels'
|
|
6
21
|
import type { MaxAttachmentSizeBytes } from './quickReplyAttachments'
|
|
@@ -8,6 +23,21 @@ import type { ConversationVariable } from './quickReply.types'
|
|
|
8
23
|
|
|
9
24
|
const TABLE_SKELETON_ROWS = 3
|
|
10
25
|
|
|
26
|
+
/** Troca o trecho pelo navegador para o Ctrl+Z desfazer a formatação; `false` quando não dá. */
|
|
27
|
+
function replaceWithNativeUndo(field: HTMLTextAreaElement, previous: string, next: string): boolean {
|
|
28
|
+
if (!canExecuteFormattingCommand()) return false
|
|
29
|
+
const range = changedRange(previous, next)
|
|
30
|
+
field.focus()
|
|
31
|
+
field.setSelectionRange(range.start, range.end)
|
|
32
|
+
try {
|
|
33
|
+
return range.insertedText
|
|
34
|
+
? document.execCommand('insertText', false, range.insertedText)
|
|
35
|
+
: document.execCommand('delete')
|
|
36
|
+
} catch {
|
|
37
|
+
return false
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
11
41
|
export interface QuickRepliesWorkspaceProps {
|
|
12
42
|
readonly api: QuickRepliesWorkspaceApi
|
|
13
43
|
/** Catálogo de variáveis oferecido pelos botões "Inserir variável" do formulário. */
|
|
@@ -106,6 +136,42 @@ export function QuickRepliesWorkspace({
|
|
|
106
136
|
})
|
|
107
137
|
}
|
|
108
138
|
|
|
139
|
+
const applyFormatting = (action: QuickReplyFormattingAction) => {
|
|
140
|
+
const field = bodyFieldRef.current
|
|
141
|
+
if (!field || !editing) return
|
|
142
|
+
const next = computeFormattingEdit({
|
|
143
|
+
text: editing.body,
|
|
144
|
+
selectionStart: field.selectionStart,
|
|
145
|
+
selectionEnd: field.selectionEnd,
|
|
146
|
+
action,
|
|
147
|
+
maximumLength: BODY_MAX_LENGTH,
|
|
148
|
+
})
|
|
149
|
+
if (!next) return
|
|
150
|
+
// O execCommand dispara o `input` nativo, e o onChange mantém o estado do React em dia.
|
|
151
|
+
// Se o navegador aplicou só parte (ou nada), o estado vence o DOM.
|
|
152
|
+
if (!replaceWithNativeUndo(field, editing.body, next.text) || field.value !== next.text) {
|
|
153
|
+
updateField('body', next.text)
|
|
154
|
+
}
|
|
155
|
+
requestAnimationFrame(() => {
|
|
156
|
+
field.focus()
|
|
157
|
+
field.setSelectionRange(next.selectionStart, next.selectionEnd)
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const handleBodyKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
|
162
|
+
const action = formattingActionForShortcut({
|
|
163
|
+
key: event.key,
|
|
164
|
+
ctrlKey: event.ctrlKey,
|
|
165
|
+
metaKey: event.metaKey,
|
|
166
|
+
shiftKey: event.shiftKey,
|
|
167
|
+
altKey: event.altKey,
|
|
168
|
+
isComposing: event.nativeEvent.isComposing,
|
|
169
|
+
})
|
|
170
|
+
if (!action) return
|
|
171
|
+
event.preventDefault()
|
|
172
|
+
applyFormatting(action)
|
|
173
|
+
}
|
|
174
|
+
|
|
109
175
|
return (
|
|
110
176
|
<div className={cn('space-y-4', className)}>
|
|
111
177
|
<header className="space-y-0.5">
|
|
@@ -131,8 +197,9 @@ export function QuickRepliesWorkspace({
|
|
|
131
197
|
<button
|
|
132
198
|
type="button"
|
|
133
199
|
onClick={startCreate}
|
|
134
|
-
className="cv-header-action ml-auto inline-flex items-center gap-1"
|
|
200
|
+
className="cv-header-action cv-header-action--primary ml-auto inline-flex items-center gap-1"
|
|
135
201
|
>
|
|
202
|
+
<Plus size={14} aria-hidden="true" />
|
|
136
203
|
{text.create}
|
|
137
204
|
</button>
|
|
138
205
|
) : null}
|
|
@@ -151,130 +218,141 @@ export function QuickRepliesWorkspace({
|
|
|
151
218
|
|
|
152
219
|
{editing ? (
|
|
153
220
|
<form
|
|
154
|
-
className="
|
|
221
|
+
className="grid gap-4 rounded-lg border border-gray-200 p-4 dark:border-gray-700 lg:grid-cols-[minmax(0,1fr)_minmax(0,22rem)]"
|
|
155
222
|
onSubmit={(event) => {
|
|
156
223
|
event.preventDefault()
|
|
157
224
|
void submit()
|
|
158
225
|
}}
|
|
159
226
|
>
|
|
160
|
-
<div className="space-y-
|
|
161
|
-
<
|
|
162
|
-
{
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
{
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
227
|
+
<div className="space-y-3">
|
|
228
|
+
<div className="space-y-1">
|
|
229
|
+
<label className="block text-sm font-medium" htmlFor={titleFieldId}>
|
|
230
|
+
{text.fieldTitle}
|
|
231
|
+
</label>
|
|
232
|
+
<input
|
|
233
|
+
id={titleFieldId}
|
|
234
|
+
type="text"
|
|
235
|
+
maxLength={40}
|
|
236
|
+
value={editing.title}
|
|
237
|
+
onChange={(event) => updateField('title', event.target.value)}
|
|
238
|
+
aria-invalid={Boolean(fieldErrors.title)}
|
|
239
|
+
aria-describedby={fieldErrors.title ? `${titleFieldId}-error` : undefined}
|
|
240
|
+
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-900"
|
|
241
|
+
/>
|
|
242
|
+
{fieldErrors.title ? (
|
|
243
|
+
<p id={`${titleFieldId}-error`} role="alert" className="text-xs text-red-600 dark:text-red-400">
|
|
244
|
+
{fieldErrors.title}
|
|
245
|
+
</p>
|
|
246
|
+
) : null}
|
|
247
|
+
</div>
|
|
180
248
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
249
|
+
<div className="space-y-1">
|
|
250
|
+
<label className="block text-sm font-medium" htmlFor={shortcutFieldId}>
|
|
251
|
+
{text.fieldShortcut}
|
|
252
|
+
</label>
|
|
253
|
+
<input
|
|
254
|
+
id={shortcutFieldId}
|
|
255
|
+
type="text"
|
|
256
|
+
maxLength={20}
|
|
257
|
+
value={editing.shortcut}
|
|
258
|
+
onChange={(event) => updateField('shortcut', event.target.value)}
|
|
259
|
+
aria-invalid={Boolean(fieldErrors.shortcut)}
|
|
260
|
+
aria-describedby={fieldErrors.shortcut ? `${shortcutFieldId}-error` : `${shortcutFieldId}-hint`}
|
|
261
|
+
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm font-mono dark:border-gray-700 dark:bg-gray-900"
|
|
262
|
+
/>
|
|
263
|
+
{fieldErrors.shortcut ? (
|
|
264
|
+
<p id={`${shortcutFieldId}-error`} role="alert" className="text-xs text-red-600 dark:text-red-400">
|
|
265
|
+
{fieldErrors.shortcut}
|
|
266
|
+
</p>
|
|
267
|
+
) : (
|
|
268
|
+
<p id={`${shortcutFieldId}-hint`} className="text-xs text-gray-400">
|
|
269
|
+
{text.fieldShortcutHint}
|
|
270
|
+
</p>
|
|
271
|
+
)}
|
|
272
|
+
</div>
|
|
205
273
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
274
|
+
<div className="space-y-1">
|
|
275
|
+
<label className="block text-sm font-medium" htmlFor={bodyFieldId}>
|
|
276
|
+
{text.fieldBody}
|
|
277
|
+
</label>
|
|
278
|
+
<QuickReplyFormattingToolbar labels={text} onFormat={applyFormatting} />
|
|
279
|
+
<textarea
|
|
280
|
+
id={bodyFieldId}
|
|
281
|
+
ref={bodyFieldRef}
|
|
282
|
+
maxLength={BODY_MAX_LENGTH}
|
|
283
|
+
rows={4}
|
|
284
|
+
value={editing.body}
|
|
285
|
+
onChange={(event) => updateField('body', event.target.value)}
|
|
286
|
+
onKeyDown={handleBodyKeyDown}
|
|
287
|
+
aria-invalid={Boolean(fieldErrors.body)}
|
|
288
|
+
aria-describedby={fieldErrors.body ? `${bodyFieldId}-error` : undefined}
|
|
289
|
+
className="w-full resize-none rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-900"
|
|
290
|
+
/>
|
|
291
|
+
{fieldErrors.body ? (
|
|
292
|
+
<p id={`${bodyFieldId}-error`} role="alert" className="text-xs text-red-600 dark:text-red-400">
|
|
293
|
+
{fieldErrors.body}
|
|
294
|
+
</p>
|
|
295
|
+
) : null}
|
|
296
|
+
{variables && variables.length > 0 ? (
|
|
297
|
+
<div className="flex flex-wrap gap-1 pt-1">
|
|
298
|
+
{variables.map((variable) => (
|
|
299
|
+
<button
|
|
300
|
+
key={variable.id}
|
|
301
|
+
type="button"
|
|
302
|
+
onClick={() => insertVariableAtCursor(variable.marker)}
|
|
303
|
+
aria-label={`${text.insertVariable}: ${variable.label}`}
|
|
304
|
+
className="rounded-full border border-gray-200 px-2 py-0.5 text-xs text-gray-600 hover:bg-gray-100 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800"
|
|
305
|
+
>
|
|
306
|
+
{variable.label}
|
|
307
|
+
</button>
|
|
308
|
+
))}
|
|
309
|
+
</div>
|
|
310
|
+
) : null}
|
|
311
|
+
</div>
|
|
242
312
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
313
|
+
{hasAttachmentsCapability ? (
|
|
314
|
+
<AttachmentsFormSection
|
|
315
|
+
labels={text}
|
|
316
|
+
attachments={editing.attachments}
|
|
317
|
+
pendingUploads={pendingUploads}
|
|
318
|
+
attachmentRejections={attachmentRejections}
|
|
319
|
+
onAddFiles={addAttachmentFiles}
|
|
320
|
+
onRetryUpload={retryAttachmentUpload}
|
|
321
|
+
onCancelUpload={cancelAttachmentUpload}
|
|
322
|
+
onRemoveAttachment={removeAttachment}
|
|
323
|
+
onMoveAttachment={moveAttachmentAt}
|
|
324
|
+
onDismissRejections={dismissAttachmentRejections}
|
|
325
|
+
/>
|
|
326
|
+
) : null}
|
|
257
327
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
328
|
+
{saveError ? (
|
|
329
|
+
<p role="alert" className="text-sm text-red-600 dark:text-red-400">
|
|
330
|
+
{saveError}
|
|
331
|
+
</p>
|
|
332
|
+
) : null}
|
|
263
333
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
334
|
+
<div className="flex items-center gap-2">
|
|
335
|
+
<button
|
|
336
|
+
type="submit"
|
|
337
|
+
disabled={isSaving || hasPendingUploads}
|
|
338
|
+
aria-busy={isSaving}
|
|
339
|
+
title={hasPendingUploads ? text.saveBlockedUploading : undefined}
|
|
340
|
+
className="cv-header-action inline-flex items-center gap-1 disabled:opacity-40"
|
|
341
|
+
>
|
|
342
|
+
{isSaving ? text.saving : hasPendingUploads ? text.saveBlockedUploading : text.save}
|
|
343
|
+
</button>
|
|
344
|
+
<button type="button" onClick={cancelEdit} className="text-sm text-gray-500 hover:underline">
|
|
345
|
+
{text.cancel}
|
|
346
|
+
</button>
|
|
347
|
+
</div>
|
|
277
348
|
</div>
|
|
349
|
+
|
|
350
|
+
<QuickReplyWhatsAppPreview
|
|
351
|
+
body={editing.body}
|
|
352
|
+
{...(variables ? { variables } : {})}
|
|
353
|
+
attachments={editing.attachments}
|
|
354
|
+
labels={text}
|
|
355
|
+
/>
|
|
278
356
|
</form>
|
|
279
357
|
) : null}
|
|
280
358
|
|
|
@@ -328,61 +406,68 @@ export function QuickRepliesWorkspace({
|
|
|
328
406
|
<td className="px-3 py-2 font-mono text-xs text-gray-500">/{quickReply.shortcut}</td>
|
|
329
407
|
<td className="hidden max-w-sm truncate px-3 py-2 text-gray-500 sm:table-cell">{quickReply.body}</td>
|
|
330
408
|
{!readOnly ? (
|
|
331
|
-
<td className="
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
) : null}
|
|
341
|
-
{canDelete ? (
|
|
342
|
-
confirmingDeleteId === quickReply.id ? (
|
|
343
|
-
// Linha de confirmação inline em vez de `window.confirm`: trava a aba
|
|
344
|
-
// inteira e não segue os tokens visuais do pacote (`web.md` §14).
|
|
345
|
-
<span
|
|
346
|
-
role="group"
|
|
347
|
-
aria-label={text.removeConfirm(quickReply.title)}
|
|
348
|
-
onKeyDown={handleDeleteConfirmKeyDown(quickReply.id)}
|
|
349
|
-
className="flex items-center gap-2 text-xs"
|
|
409
|
+
<td className="px-3 py-2">
|
|
410
|
+
<div className="flex items-center gap-1">
|
|
411
|
+
{canEdit ? (
|
|
412
|
+
<button
|
|
413
|
+
type="button"
|
|
414
|
+
onClick={() => startEdit(quickReply)}
|
|
415
|
+
data-cv-tooltip={text.edit}
|
|
416
|
+
aria-label={`${text.edit}: ${quickReply.title}`}
|
|
417
|
+
className="cv-header-icon"
|
|
350
418
|
>
|
|
351
|
-
{
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
419
|
+
<Pencil size={14} aria-hidden="true" />
|
|
420
|
+
</button>
|
|
421
|
+
) : null}
|
|
422
|
+
{canDelete ? (
|
|
423
|
+
confirmingDeleteId === quickReply.id ? (
|
|
424
|
+
// Linha de confirmação inline em vez de `window.confirm`: trava a aba
|
|
425
|
+
// inteira e não segue os tokens visuais do pacote (`web.md` §14).
|
|
426
|
+
<span
|
|
427
|
+
role="group"
|
|
428
|
+
aria-label={text.removeConfirm(quickReply.title)}
|
|
429
|
+
onKeyDown={handleDeleteConfirmKeyDown(quickReply.id)}
|
|
430
|
+
className="flex items-center gap-2 text-xs"
|
|
360
431
|
>
|
|
361
|
-
{text.
|
|
362
|
-
|
|
432
|
+
{text.removeConfirm(quickReply.title)}
|
|
433
|
+
<button
|
|
434
|
+
ref={confirmButtonRef}
|
|
435
|
+
type="button"
|
|
436
|
+
onClick={() => {
|
|
437
|
+
setConfirmingDeleteId(null)
|
|
438
|
+
void remove(quickReply.id)
|
|
439
|
+
}}
|
|
440
|
+
className="cv-header-action cv-header-action--danger inline-flex items-center gap-1"
|
|
441
|
+
>
|
|
442
|
+
<Trash2 size={12} aria-hidden="true" />
|
|
443
|
+
{text.remove}
|
|
444
|
+
</button>
|
|
445
|
+
<button
|
|
446
|
+
type="button"
|
|
447
|
+
onClick={() => cancelDeleteConfirm(quickReply.id)}
|
|
448
|
+
className="cv-header-action"
|
|
449
|
+
>
|
|
450
|
+
{text.cancel}
|
|
451
|
+
</button>
|
|
452
|
+
</span>
|
|
453
|
+
) : (
|
|
363
454
|
<button
|
|
455
|
+
ref={(node) => {
|
|
456
|
+
deleteButtonRefs.current[quickReply.id] = node
|
|
457
|
+
}}
|
|
364
458
|
type="button"
|
|
365
|
-
|
|
366
|
-
|
|
459
|
+
disabled={deletingId === quickReply.id}
|
|
460
|
+
aria-busy={deletingId === quickReply.id}
|
|
461
|
+
onClick={() => setConfirmingDeleteId(quickReply.id)}
|
|
462
|
+
data-cv-tooltip={deletingId === quickReply.id ? text.deleting : text.remove}
|
|
463
|
+
aria-label={`${deletingId === quickReply.id ? text.deleting : text.remove}: ${quickReply.title}`}
|
|
464
|
+
className="cv-header-icon disabled:opacity-40"
|
|
367
465
|
>
|
|
368
|
-
{text
|
|
466
|
+
<Trash2 size={14} aria-hidden="true" className="text-red-500" />
|
|
369
467
|
</button>
|
|
370
|
-
|
|
371
|
-
) :
|
|
372
|
-
|
|
373
|
-
ref={(node) => {
|
|
374
|
-
deleteButtonRefs.current[quickReply.id] = node
|
|
375
|
-
}}
|
|
376
|
-
type="button"
|
|
377
|
-
disabled={deletingId === quickReply.id}
|
|
378
|
-
aria-busy={deletingId === quickReply.id}
|
|
379
|
-
onClick={() => setConfirmingDeleteId(quickReply.id)}
|
|
380
|
-
className="text-xs text-red-600 hover:underline disabled:opacity-50 dark:text-red-400"
|
|
381
|
-
>
|
|
382
|
-
{deletingId === quickReply.id ? text.deleting : text.remove}
|
|
383
|
-
</button>
|
|
384
|
-
)
|
|
385
|
-
) : null}
|
|
468
|
+
)
|
|
469
|
+
) : null}
|
|
470
|
+
</div>
|
|
386
471
|
</td>
|
|
387
472
|
) : null}
|
|
388
473
|
</tr>
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026 Ada Technology. MIT License.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Bold, Code, Italic, List, ListOrdered, Quote, SquareCode, Strikethrough } from 'lucide-react'
|
|
6
|
+
|
|
7
|
+
import { COMPOSER_TOOL_BUTTON_CLASS, COMPOSER_TOOL_BUTTON_IDLE_CLASS } from '../composer.constant'
|
|
8
|
+
import { cn } from '../lib/cn'
|
|
9
|
+
import type { ResolvedQuickRepliesWorkspaceLabels } from './labels'
|
|
10
|
+
import {
|
|
11
|
+
FORMATTING_SHORTCUT_HINT,
|
|
12
|
+
QUICK_REPLY_FORMATTING_ACTION as ACTION,
|
|
13
|
+
type QuickReplyFormattingAction,
|
|
14
|
+
} from './quickReplyFormatting'
|
|
15
|
+
|
|
16
|
+
export interface QuickReplyFormattingToolbarProps {
|
|
17
|
+
readonly labels: Pick<
|
|
18
|
+
ResolvedQuickRepliesWorkspaceLabels,
|
|
19
|
+
| 'formatBold'
|
|
20
|
+
| 'formatItalic'
|
|
21
|
+
| 'formatStrikethrough'
|
|
22
|
+
| 'formatMonospace'
|
|
23
|
+
| 'formatInlineCode'
|
|
24
|
+
| 'formatBulletedList'
|
|
25
|
+
| 'formatNumberedList'
|
|
26
|
+
| 'formatQuote'
|
|
27
|
+
| 'formattingToolbar'
|
|
28
|
+
>
|
|
29
|
+
readonly onFormat: (action: QuickReplyFormattingAction) => void
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function QuickReplyFormattingToolbar({ labels, onFormat }: QuickReplyFormattingToolbarProps) {
|
|
33
|
+
const buttons = [
|
|
34
|
+
{ action: ACTION.BOLD, label: labels.formatBold, Icon: Bold },
|
|
35
|
+
{ action: ACTION.ITALIC, label: labels.formatItalic, Icon: Italic },
|
|
36
|
+
{ action: ACTION.STRIKETHROUGH, label: labels.formatStrikethrough, Icon: Strikethrough },
|
|
37
|
+
{ action: ACTION.MONOSPACE, label: labels.formatMonospace, Icon: SquareCode },
|
|
38
|
+
{ action: ACTION.INLINE_CODE, label: labels.formatInlineCode, Icon: Code },
|
|
39
|
+
{ action: ACTION.BULLETED_LIST, label: labels.formatBulletedList, Icon: List },
|
|
40
|
+
{ action: ACTION.NUMBERED_LIST, label: labels.formatNumberedList, Icon: ListOrdered },
|
|
41
|
+
{ action: ACTION.QUOTE, label: labels.formatQuote, Icon: Quote },
|
|
42
|
+
] as const
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div role="toolbar" aria-label={labels.formattingToolbar} className="flex flex-wrap gap-1">
|
|
46
|
+
{buttons.map(({ action, label, Icon }) => (
|
|
47
|
+
<button
|
|
48
|
+
key={action}
|
|
49
|
+
type="button"
|
|
50
|
+
aria-label={label}
|
|
51
|
+
title={FORMATTING_SHORTCUT_HINT[action] ? `${label} (${FORMATTING_SHORTCUT_HINT[action]})` : label}
|
|
52
|
+
// Clicar tiraria o foco do campo e perderia a seleção antes do wrap.
|
|
53
|
+
onMouseDown={(event) => event.preventDefault()}
|
|
54
|
+
onClick={() => onFormat(action)}
|
|
55
|
+
className={cn(COMPOSER_TOOL_BUTTON_CLASS, COMPOSER_TOOL_BUTTON_IDLE_CLASS)}
|
|
56
|
+
>
|
|
57
|
+
<Icon size={16} aria-hidden="true" />
|
|
58
|
+
</button>
|
|
59
|
+
))}
|
|
60
|
+
</div>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026 Ada Technology. MIT License.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Paperclip } from 'lucide-react'
|
|
6
|
+
|
|
7
|
+
import { parseWhatsAppFormatting } from '../lib/whatsapp-formatting'
|
|
8
|
+
import { WhatsAppMessagePreview } from '../lib/WhatsAppMessagePreview'
|
|
9
|
+
import type { ResolvedQuickRepliesWorkspaceLabels } from './labels'
|
|
10
|
+
import type { ConversationVariable, QuickReplyAttachment } from './quickReply.types'
|
|
11
|
+
|
|
12
|
+
export interface QuickReplyWhatsAppPreviewProps {
|
|
13
|
+
readonly body: string
|
|
14
|
+
readonly variables?: readonly ConversationVariable[]
|
|
15
|
+
readonly attachments?: readonly QuickReplyAttachment[]
|
|
16
|
+
readonly labels: Pick<ResolvedQuickRepliesWorkspaceLabels, 'previewTitle' | 'previewEmptyBody'>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Troca cada marcador pelo exemplo da variável (ou o rótulo, sem exemplo); sem catálogo, fica o marcador. */
|
|
20
|
+
export function resolvePreviewVariables(body: string, variables: readonly ConversationVariable[] = []): string {
|
|
21
|
+
return variables.reduce(
|
|
22
|
+
(resolved, variable) =>
|
|
23
|
+
variable.marker ? resolved.split(variable.marker).join(variable.value || variable.label) : resolved,
|
|
24
|
+
body,
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function QuickReplyWhatsAppPreview({ body, variables, attachments, labels }: QuickReplyWhatsAppPreviewProps) {
|
|
29
|
+
const resolvedBody = resolvePreviewVariables(body, variables)
|
|
30
|
+
const hasAttachments = (attachments?.length ?? 0) > 0
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<section aria-label={labels.previewTitle} className="space-y-1">
|
|
34
|
+
<h3 className="text-sm font-medium">{labels.previewTitle}</h3>
|
|
35
|
+
<WhatsAppMessagePreview>
|
|
36
|
+
{hasAttachments ? (
|
|
37
|
+
<ul className="mb-1.5 flex flex-wrap gap-1 whitespace-normal">
|
|
38
|
+
{attachments?.map((attachment) => (
|
|
39
|
+
<li
|
|
40
|
+
key={attachment.uploadId}
|
|
41
|
+
className="inline-flex max-w-full items-center gap-1 rounded-md bg-black/5 px-1.5 py-0.5 text-xs dark:bg-white/10"
|
|
42
|
+
>
|
|
43
|
+
<Paperclip size={12} aria-hidden="true" className="shrink-0" />
|
|
44
|
+
<span className="truncate">{attachment.filename}</span>
|
|
45
|
+
</li>
|
|
46
|
+
))}
|
|
47
|
+
</ul>
|
|
48
|
+
) : null}
|
|
49
|
+
{resolvedBody ? (
|
|
50
|
+
parseWhatsAppFormatting(resolvedBody)
|
|
51
|
+
) : (
|
|
52
|
+
<span className="italic text-gray-400">{labels.previewEmptyBody}</span>
|
|
53
|
+
)}
|
|
54
|
+
</WhatsAppMessagePreview>
|
|
55
|
+
</section>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
@@ -74,9 +74,24 @@ export interface QuickRepliesWorkspaceLabels {
|
|
|
74
74
|
readonly attachmentLimitReached: string
|
|
75
75
|
readonly attachmentTooLarge: (filename: string) => string
|
|
76
76
|
readonly saveBlockedUploading: string
|
|
77
|
+
/** Barra de formatação acima do campo de texto — notação do WhatsApp. */
|
|
78
|
+
readonly formatBold?: string
|
|
79
|
+
readonly formatItalic?: string
|
|
80
|
+
readonly formatStrikethrough?: string
|
|
81
|
+
readonly formatMonospace?: string
|
|
82
|
+
readonly formatInlineCode?: string
|
|
83
|
+
readonly formatBulletedList?: string
|
|
84
|
+
readonly formatNumberedList?: string
|
|
85
|
+
readonly formatQuote?: string
|
|
86
|
+
readonly formattingToolbar?: string
|
|
87
|
+
readonly previewTitle?: string
|
|
88
|
+
readonly previewEmptyBody?: string
|
|
77
89
|
}
|
|
78
90
|
|
|
79
|
-
|
|
91
|
+
/** Os rótulos já completados pelos padrões — as chaves novas são opcionais no tipo público. */
|
|
92
|
+
export type ResolvedQuickRepliesWorkspaceLabels = Required<QuickRepliesWorkspaceLabels>
|
|
93
|
+
|
|
94
|
+
export const DEFAULT_QUICK_REPLIES_WORKSPACE_LABELS: ResolvedQuickRepliesWorkspaceLabels = {
|
|
80
95
|
title: 'Mensagens prontas',
|
|
81
96
|
subtitle: (total) => `${total} ${total === 1 ? 'mensagem cadastrada' : 'mensagens cadastradas'}`,
|
|
82
97
|
searchPlaceholder: 'Buscar por título, atalho ou texto',
|
|
@@ -121,4 +136,15 @@ export const DEFAULT_QUICK_REPLIES_WORKSPACE_LABELS: QuickRepliesWorkspaceLabels
|
|
|
121
136
|
attachmentLimitReached: 'Limite de 10 anexos por mensagem.',
|
|
122
137
|
attachmentTooLarge: (filename) => `${filename}: excede o tamanho máximo para o tipo de arquivo.`,
|
|
123
138
|
saveBlockedUploading: 'Aguardando o envio dos anexos…',
|
|
139
|
+
formatBold: 'Negrito',
|
|
140
|
+
formatItalic: 'Itálico',
|
|
141
|
+
formatStrikethrough: 'Tachado',
|
|
142
|
+
formatMonospace: 'Monoespaçado',
|
|
143
|
+
formatInlineCode: 'Código',
|
|
144
|
+
formatBulletedList: 'Lista',
|
|
145
|
+
formatNumberedList: 'Lista numerada',
|
|
146
|
+
formatQuote: 'Citação',
|
|
147
|
+
formattingToolbar: 'Formatação do texto',
|
|
148
|
+
previewTitle: 'Pré-visualização no WhatsApp',
|
|
149
|
+
previewEmptyBody: 'Digite o texto para ver como a mensagem chega ao cliente.',
|
|
124
150
|
}
|