@adatechnology/conversations-ui 0.2.0 → 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.
@@ -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 { useQuickRepliesWorkspace, insertAtCursor, type QuickRepliesWorkspaceApi } from './useQuickRepliesWorkspace'
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="space-y-3 rounded-lg border border-gray-200 p-4 dark:border-gray-700"
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-1">
161
- <label className="block text-sm font-medium" htmlFor={titleFieldId}>
162
- {text.fieldTitle}
163
- </label>
164
- <input
165
- id={titleFieldId}
166
- type="text"
167
- maxLength={40}
168
- value={editing.title}
169
- onChange={(event) => updateField('title', event.target.value)}
170
- aria-invalid={Boolean(fieldErrors.title)}
171
- aria-describedby={fieldErrors.title ? `${titleFieldId}-error` : undefined}
172
- className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-900"
173
- />
174
- {fieldErrors.title ? (
175
- <p id={`${titleFieldId}-error`} role="alert" className="text-xs text-red-600 dark:text-red-400">
176
- {fieldErrors.title}
177
- </p>
178
- ) : null}
179
- </div>
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
- <div className="space-y-1">
182
- <label className="block text-sm font-medium" htmlFor={shortcutFieldId}>
183
- {text.fieldShortcut}
184
- </label>
185
- <input
186
- id={shortcutFieldId}
187
- type="text"
188
- maxLength={20}
189
- value={editing.shortcut}
190
- onChange={(event) => updateField('shortcut', event.target.value)}
191
- aria-invalid={Boolean(fieldErrors.shortcut)}
192
- aria-describedby={fieldErrors.shortcut ? `${shortcutFieldId}-error` : `${shortcutFieldId}-hint`}
193
- 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"
194
- />
195
- {fieldErrors.shortcut ? (
196
- <p id={`${shortcutFieldId}-error`} role="alert" className="text-xs text-red-600 dark:text-red-400">
197
- {fieldErrors.shortcut}
198
- </p>
199
- ) : (
200
- <p id={`${shortcutFieldId}-hint`} className="text-xs text-gray-400">
201
- {text.fieldShortcutHint}
202
- </p>
203
- )}
204
- </div>
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
- <div className="space-y-1">
207
- <label className="block text-sm font-medium" htmlFor={bodyFieldId}>
208
- {text.fieldBody}
209
- </label>
210
- <textarea
211
- id={bodyFieldId}
212
- ref={bodyFieldRef}
213
- maxLength={1000}
214
- rows={4}
215
- value={editing.body}
216
- onChange={(event) => updateField('body', event.target.value)}
217
- aria-invalid={Boolean(fieldErrors.body)}
218
- aria-describedby={fieldErrors.body ? `${bodyFieldId}-error` : undefined}
219
- 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"
220
- />
221
- {fieldErrors.body ? (
222
- <p id={`${bodyFieldId}-error`} role="alert" className="text-xs text-red-600 dark:text-red-400">
223
- {fieldErrors.body}
224
- </p>
225
- ) : null}
226
- {variables && variables.length > 0 ? (
227
- <div className="flex flex-wrap gap-1 pt-1">
228
- {variables.map((variable) => (
229
- <button
230
- key={variable.id}
231
- type="button"
232
- onClick={() => insertVariableAtCursor(variable.marker)}
233
- aria-label={`${text.insertVariable}: ${variable.label}`}
234
- 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"
235
- >
236
- {variable.label}
237
- </button>
238
- ))}
239
- </div>
240
- ) : null}
241
- </div>
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
- {hasAttachmentsCapability ? (
244
- <AttachmentsFormSection
245
- labels={text}
246
- attachments={editing.attachments}
247
- pendingUploads={pendingUploads}
248
- attachmentRejections={attachmentRejections}
249
- onAddFiles={addAttachmentFiles}
250
- onRetryUpload={retryAttachmentUpload}
251
- onCancelUpload={cancelAttachmentUpload}
252
- onRemoveAttachment={removeAttachment}
253
- onMoveAttachment={moveAttachmentAt}
254
- onDismissRejections={dismissAttachmentRejections}
255
- />
256
- ) : null}
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
- {saveError ? (
259
- <p role="alert" className="text-sm text-red-600 dark:text-red-400">
260
- {saveError}
261
- </p>
262
- ) : null}
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
- <div className="flex items-center gap-2">
265
- <button
266
- type="submit"
267
- disabled={isSaving || hasPendingUploads}
268
- aria-busy={isSaving}
269
- title={hasPendingUploads ? text.saveBlockedUploading : undefined}
270
- className="cv-header-action inline-flex items-center gap-1 disabled:opacity-40"
271
- >
272
- {isSaving ? text.saving : hasPendingUploads ? text.saveBlockedUploading : text.save}
273
- </button>
274
- <button type="button" onClick={cancelEdit} className="text-sm text-gray-500 hover:underline">
275
- {text.cancel}
276
- </button>
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="flex gap-2 px-3 py-2">
332
- {canEdit ? (
333
- <button
334
- type="button"
335
- onClick={() => startEdit(quickReply)}
336
- className="text-xs text-blue-600 hover:underline dark:text-blue-400"
337
- >
338
- {text.edit}
339
- </button>
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
- {text.removeConfirm(quickReply.title)}
352
- <button
353
- ref={confirmButtonRef}
354
- type="button"
355
- onClick={() => {
356
- setConfirmingDeleteId(null)
357
- void remove(quickReply.id)
358
- }}
359
- className="font-medium text-red-600 hover:underline dark:text-red-400"
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.remove}
362
- </button>
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
- onClick={() => cancelDeleteConfirm(quickReply.id)}
366
- className="text-gray-500 hover:underline dark:text-gray-400"
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.cancel}
466
+ <Trash2 size={14} aria-hidden="true" className="text-red-500" />
369
467
  </button>
370
- </span>
371
- ) : (
372
- <button
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
- export const DEFAULT_QUICK_REPLIES_WORKSPACE_LABELS: QuickRepliesWorkspaceLabels = {
91
+ /** Os rótulos 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
  }