@adatechnology/conversations-ui 0.1.1 → 0.2.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.
Files changed (49) hide show
  1. package/dist/{ConversationSimulatorPanel--5fIzXWY.d.ts → ConversationSimulatorPanel-ikgmlciE.d.ts} +103 -1
  2. package/dist/{chunk-BJNRLLDO.js → chunk-NHQDQJL2.js} +769 -244
  3. package/dist/index.d.ts +304 -9
  4. package/dist/index.js +2166 -509
  5. package/dist/preview/index.d.ts +2 -2
  6. package/dist/preview/index.js +147 -1
  7. package/dist/styles.css +135 -0
  8. package/package.json +1 -1
  9. package/src/MessageComposer.test.tsx +14 -0
  10. package/src/MessageComposer.tsx +327 -73
  11. package/src/RichMessageComposer.test.tsx +22 -4
  12. package/src/RichMessageComposer.tsx +674 -370
  13. package/src/index.ts +28 -1
  14. package/src/preview/createMockConversationsApi.ts +184 -0
  15. package/src/providers/types.ts +34 -0
  16. package/src/quickReplies/AttachmentsFormSection.tsx +160 -0
  17. package/src/quickReplies/QuickRepliesPicker.test.tsx +114 -0
  18. package/src/quickReplies/QuickRepliesPicker.tsx +188 -0
  19. package/src/quickReplies/QuickRepliesWorkspace.test.tsx +32 -0
  20. package/src/quickReplies/QuickRepliesWorkspace.tsx +395 -0
  21. package/src/quickReplies/createUploadQueue.test.ts +106 -0
  22. package/src/quickReplies/createUploadQueue.ts +69 -0
  23. package/src/quickReplies/index.ts +5 -0
  24. package/src/quickReplies/labels.ts +124 -0
  25. package/src/quickReplies/quickReply.types.ts +74 -0
  26. package/src/quickReplies/quickReplyAttachmentUpload.test.ts +76 -0
  27. package/src/quickReplies/quickReplyAttachmentUpload.ts +81 -0
  28. package/src/quickReplies/quickReplyAttachments.test.ts +396 -0
  29. package/src/quickReplies/quickReplyAttachments.ts +289 -0
  30. package/src/quickReplies/quickReplySearch.test.ts +88 -0
  31. package/src/quickReplies/quickReplySearch.ts +104 -0
  32. package/src/quickReplies/quickReplyShortcut.test.ts +38 -0
  33. package/src/quickReplies/quickReplyShortcut.ts +46 -0
  34. package/src/quickReplies/resolveConversationVariables.test.ts +63 -0
  35. package/src/quickReplies/resolveConversationVariables.ts +41 -0
  36. package/src/quickReplies/useQuickRepliesPicker.test.ts +20 -0
  37. package/src/quickReplies/useQuickRepliesPicker.ts +121 -0
  38. package/src/quickReplies/useQuickRepliesWorkspace.test.ts +222 -0
  39. package/src/quickReplies/useQuickRepliesWorkspace.ts +426 -0
  40. package/src/quickReplies/useQuickReplyAttachmentUploads.ts +159 -0
  41. package/src/styles.css +87 -0
  42. package/src/workspace/ConversationPane.tsx +137 -73
  43. package/src/workspace/ConversationsWorkspace.tsx +16 -1
  44. package/src/workspace/QueuedAttachmentsList.test.ts +27 -0
  45. package/src/workspace/QueuedAttachmentsList.tsx +198 -0
  46. package/src/workspace/index.ts +1 -0
  47. package/src/workspace/labels.ts +14 -0
  48. package/src/workspace/useComposerAttachmentRetry.ts +155 -0
  49. package/src/workspace/useComposerQueue.ts +220 -0
@@ -0,0 +1,188 @@
1
+ import { useEffect, useRef, type KeyboardEvent } from 'react'
2
+ import { Paperclip } from 'lucide-react'
3
+ import { applyQuickReplyVariables } from '../MessageComposer'
4
+ import { cn } from '../lib/cn'
5
+ import { highlightMatch } from './quickReplySearch'
6
+ import type { QuickReply } from './quickReply.types'
7
+ import { DEFAULT_QUICK_REPLIES_PICKER_LABELS, type QuickRepliesPickerLabels } from './labels'
8
+
9
+ const LOADING_SKELETON_ROWS = 4
10
+
11
+ /**
12
+ * Presente só no modo botão: o picker desenha e é dono da própria busca — focada ao abrir, dona de
13
+ * seta/Enter/Esc. O campo de mensagem não é tocado (QR-04): abrir pelo raio nunca insere `/` nele.
14
+ * Ausente, a busca vem do atalho `/termo` digitado no próprio campo, que continua no comando.
15
+ */
16
+ export type QuickRepliesPickerOwnSearch = {
17
+ readonly value: string
18
+ readonly onChange: (value: string) => void
19
+ readonly onKeyDown: (event: KeyboardEvent<HTMLInputElement>) => void
20
+ readonly label: string
21
+ }
22
+
23
+ export type QuickRepliesPickerProps = {
24
+ /** Id do `listbox`, para o campo (ou a busca própria) apontar `aria-activedescendant`. */
25
+ readonly id: string
26
+ readonly items: readonly QuickReply[]
27
+ readonly search: string
28
+ readonly highlightedIndex: number
29
+ readonly isLoading: boolean
30
+ /** Sinalizador puro — o texto exibido é sempre `labels.error`, nunca mensagem crua de exceção. */
31
+ readonly hasError?: boolean
32
+ readonly onHover: (index: number) => void
33
+ readonly onSelect: (quickReply: QuickReply) => void
34
+ readonly labels?: Partial<QuickRepliesPickerLabels>
35
+ readonly className?: string
36
+ readonly ownSearch?: QuickRepliesPickerOwnSearch
37
+ /** Resolve `{{marcador}}` antes de destacar e mostrar a prévia (QR-04) — quem busca "joão" espera
38
+ * ver "Olá João" na lista, não o marcador cru. */
39
+ readonly variables?: Readonly<Record<string, string>>
40
+ /** Sem `sendStoredAttachments` no host, a linha com anexo avisa em vez de prometer envio (QR-33). */
41
+ readonly hasAttachmentsCapability?: boolean
42
+ }
43
+
44
+ /**
45
+ * Lista flutuante do atalho `/` e do botão de raio. Puramente controlada — todo estado (busca,
46
+ * destaque, carregamento) vem de `useQuickRepliesPicker`, este componente só desenha.
47
+ */
48
+ export function QuickRepliesPicker({
49
+ id,
50
+ items,
51
+ search,
52
+ highlightedIndex,
53
+ isLoading,
54
+ hasError,
55
+ onHover,
56
+ onSelect,
57
+ labels,
58
+ className,
59
+ ownSearch,
60
+ variables,
61
+ hasAttachmentsCapability,
62
+ }: QuickRepliesPickerProps) {
63
+ const text = { ...DEFAULT_QUICK_REPLIES_PICKER_LABELS, ...labels }
64
+ const searchInputRef = useRef<HTMLInputElement>(null)
65
+
66
+ useEffect(() => {
67
+ if (ownSearch) searchInputRef.current?.focus()
68
+ // Só ao montar: o picker do modo botão nasce com a busca já em foco.
69
+ // eslint-disable-next-line react-hooks/exhaustive-deps
70
+ }, [])
71
+
72
+ const activeOptionId = items.length > 0 ? `${id}-option-${highlightedIndex}` : undefined
73
+
74
+ return (
75
+ <div className={className}>
76
+ {ownSearch ? (
77
+ <input
78
+ ref={searchInputRef}
79
+ type="text"
80
+ role="combobox"
81
+ aria-expanded="true"
82
+ aria-controls={id}
83
+ aria-activedescendant={activeOptionId}
84
+ value={ownSearch.value}
85
+ onChange={(event) => ownSearch.onChange(event.target.value)}
86
+ onKeyDown={ownSearch.onKeyDown}
87
+ placeholder={ownSearch.label}
88
+ aria-label={ownSearch.label}
89
+ className="mb-1 w-72 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm outline-none focus:border-gray-400 dark:border-gray-700 dark:bg-gray-900"
90
+ />
91
+ ) : null}
92
+ <ul
93
+ role="listbox"
94
+ id={id}
95
+ className="max-h-64 w-72 overflow-y-auto rounded-lg border border-gray-200 bg-white py-1 shadow-lg dark:border-gray-700 dark:bg-gray-900"
96
+ >
97
+ {isLoading ? (
98
+ <li aria-busy="true" aria-live="polite">
99
+ <span className="sr-only">{text.loading}</span>
100
+ {Array.from({ length: LOADING_SKELETON_ROWS }).map((_, index) => (
101
+ <span key={index} className="flex flex-col gap-1 px-3 py-2">
102
+ <span className="cv-skeleton-line" style={{ width: '60%', height: '0.75rem' }} />
103
+ <span className="cv-skeleton-line" style={{ width: '85%', height: '0.625rem' }} />
104
+ </span>
105
+ ))}
106
+ </li>
107
+ ) : null}
108
+ {!isLoading && hasError ? (
109
+ <li role="alert" className="px-3 py-2 text-sm text-red-600 dark:text-red-400">
110
+ {text.error}
111
+ </li>
112
+ ) : null}
113
+ {!isLoading && !hasError && items.length === 0 ? (
114
+ <li className="px-3 py-2 text-sm text-gray-500 dark:text-gray-400">
115
+ {search.trim() ? text.noResults : text.empty}
116
+ </li>
117
+ ) : null}
118
+ {!isLoading && !hasError
119
+ ? items.map((quickReply, index) => {
120
+ const resolvedBody = applyQuickReplyVariables(quickReply.body, variables)
121
+ return (
122
+ <li
123
+ key={quickReply.id}
124
+ id={`${id}-option-${index}`}
125
+ role="option"
126
+ aria-selected={index === highlightedIndex}
127
+ className={cn(
128
+ 'flex cursor-pointer flex-col gap-0.5 px-3 py-2 text-sm',
129
+ index === highlightedIndex ? 'bg-gray-100 dark:bg-gray-800' : undefined,
130
+ )}
131
+ onMouseEnter={() => onHover(index)}
132
+ // `mousedown` (não `click`): o campo mantém o foco, então o `blur` não fecha o
133
+ // picker antes da seleção acontecer.
134
+ onMouseDown={(event) => {
135
+ event.preventDefault()
136
+ onSelect(quickReply)
137
+ }}
138
+ >
139
+ <span className="flex items-center gap-2">
140
+ <span className="font-mono text-xs text-gray-400">/{quickReply.shortcut}</span>
141
+ <span className="truncate font-medium">
142
+ {highlightMatch(quickReply.title, search).map((segment, segmentIndex) =>
143
+ segment.isMatch ? (
144
+ <mark key={segmentIndex} className="rounded-sm bg-yellow-200 dark:bg-yellow-700/60">
145
+ {segment.text}
146
+ </mark>
147
+ ) : (
148
+ <span key={segmentIndex}>{segment.text}</span>
149
+ ),
150
+ )}
151
+ </span>
152
+ {quickReply.attachments && quickReply.attachments.length > 0 ? (
153
+ <span
154
+ className={cn(
155
+ 'flex flex-none items-center gap-0.5 text-xs',
156
+ hasAttachmentsCapability
157
+ ? 'text-gray-500 dark:text-gray-400'
158
+ : 'text-amber-600 dark:text-amber-400',
159
+ )}
160
+ title={hasAttachmentsCapability ? undefined : text.attachmentsUnavailable}
161
+ >
162
+ <Paperclip aria-hidden="true" className="h-3 w-3" />
163
+ {text.attachmentsCount(quickReply.attachments.length)}
164
+ </span>
165
+ ) : null}
166
+ </span>
167
+ {quickReply.attachments && quickReply.attachments.length > 0 && !hasAttachmentsCapability ? (
168
+ <span className="text-xs text-amber-600 dark:text-amber-400">{text.attachmentsUnavailable}</span>
169
+ ) : null}
170
+ <span className="truncate text-xs text-gray-500 dark:text-gray-400">
171
+ {highlightMatch(resolvedBody, search).map((segment, segmentIndex) =>
172
+ segment.isMatch ? (
173
+ <mark key={segmentIndex} className="rounded-sm bg-yellow-200 dark:bg-yellow-700/60">
174
+ {segment.text}
175
+ </mark>
176
+ ) : (
177
+ <span key={segmentIndex}>{segment.text}</span>
178
+ ),
179
+ )}
180
+ </span>
181
+ </li>
182
+ )
183
+ })
184
+ : null}
185
+ </ul>
186
+ </div>
187
+ )
188
+ }
@@ -0,0 +1,32 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+
4
+ import { QuickRepliesWorkspace } from './QuickRepliesWorkspace'
5
+
6
+ describe('QuickRepliesWorkspace', () => {
7
+ it('sem createQuickReply, fica só leitura — sem "nova mensagem" e sem coluna de ações', () => {
8
+ const markup = renderToStaticMarkup(<QuickRepliesWorkspace api={{}} />)
9
+
10
+ expect(markup).not.toContain('Nova mensagem')
11
+ expect(markup).not.toContain('Ações')
12
+ expect(markup).toContain('Você só pode consultar as mensagens prontas.')
13
+ })
14
+
15
+ it('com createQuickReply, mostra o botão de criar e a coluna de ações', () => {
16
+ const markup = renderToStaticMarkup(
17
+ <QuickRepliesWorkspace
18
+ api={{ createQuickReply: async () => ({ id: '1', title: 'T', shortcut: 't', body: 'B' }) }}
19
+ />,
20
+ )
21
+
22
+ expect(markup).toContain('Nova mensagem')
23
+ expect(markup).not.toContain('Você só pode consultar as mensagens prontas.')
24
+ })
25
+
26
+ it('deixa o host trocar o título sem mexer no resto dos textos', () => {
27
+ const markup = renderToStaticMarkup(<QuickRepliesWorkspace api={{}} labels={{ title: 'Respostas rápidas' }} />)
28
+
29
+ expect(markup).toContain('Respostas rápidas')
30
+ expect(markup).toContain('Buscar por título, atalho ou texto')
31
+ })
32
+ })
@@ -0,0 +1,395 @@
1
+ import { useEffect, useId, useRef, useState, type KeyboardEvent } from 'react'
2
+ import { cn } from '../lib/cn'
3
+ import { useQuickRepliesWorkspace, insertAtCursor, type QuickRepliesWorkspaceApi } from './useQuickRepliesWorkspace'
4
+ import { AttachmentsFormSection } from './AttachmentsFormSection'
5
+ import { DEFAULT_QUICK_REPLIES_WORKSPACE_LABELS, type QuickRepliesWorkspaceLabels } from './labels'
6
+ import type { MaxAttachmentSizeBytes } from './quickReplyAttachments'
7
+ import type { ConversationVariable } from './quickReply.types'
8
+
9
+ const TABLE_SKELETON_ROWS = 3
10
+
11
+ export interface QuickRepliesWorkspaceProps {
12
+ readonly api: QuickRepliesWorkspaceApi
13
+ /** Catálogo de variáveis oferecido pelos botões "Inserir variável" do formulário. */
14
+ readonly variables?: readonly ConversationVariable[]
15
+ readonly labels?: Partial<QuickRepliesWorkspaceLabels>
16
+ readonly className?: string
17
+ /** Sobrescreve o teto por tipo de arquivo. Ausente, usa `DEFAULT_MAX_ATTACHMENT_SIZE_BYTES`. */
18
+ readonly attachmentSizeLimits?: MaxAttachmentSizeBytes
19
+ }
20
+
21
+ /**
22
+ * Tela de gestão das mensagens prontas: tabela com busca e zebra, formulário de criação/edição, e
23
+ * exclusão com confirmação. Some o formulário quando o host não passa `createQuickReply` — vira
24
+ * consulta, a mesma regra de capacidade do resto do pacote.
25
+ */
26
+ export function QuickRepliesWorkspace({
27
+ api,
28
+ variables,
29
+ labels,
30
+ className,
31
+ attachmentSizeLimits,
32
+ }: QuickRepliesWorkspaceProps) {
33
+ const text = { ...DEFAULT_QUICK_REPLIES_WORKSPACE_LABELS, ...labels }
34
+ const bodyFieldRef = useRef<HTMLTextAreaElement>(null)
35
+ const formId = useId()
36
+ const titleFieldId = `${formId}-title`
37
+ const shortcutFieldId = `${formId}-shortcut`
38
+ const bodyFieldId = `${formId}-body`
39
+ /** Linha da tabela pedindo confirmação antes de excluir — em vez de `window.confirm`, que trava a
40
+ * aba inteira e não segue os tokens visuais do pacote. */
41
+ const [confirmingDeleteId, setConfirmingDeleteId] = useState<string | null>(null)
42
+ /** Um botão "Excluir" por linha — para onde o foco volta quando a confirmação é cancelada. */
43
+ const deleteButtonRefs = useRef<Record<string, HTMLButtonElement | null>>({})
44
+ const confirmButtonRef = useRef<HTMLButtonElement>(null)
45
+
46
+ // A confirmação nasce sem foco nenhum: sem isto, Tab a partir de onde o operador estava levaria
47
+ // por cima dela, e quem usa teclado nunca saberia que ela apareceu.
48
+ useEffect(() => {
49
+ if (confirmingDeleteId) confirmButtonRef.current?.focus()
50
+ }, [confirmingDeleteId])
51
+
52
+ const cancelDeleteConfirm = (id: string) => {
53
+ setConfirmingDeleteId(null)
54
+ deleteButtonRefs.current[id]?.focus()
55
+ }
56
+
57
+ const handleDeleteConfirmKeyDown = (id: string) => (event: KeyboardEvent<HTMLSpanElement>) => {
58
+ if (event.key === 'Escape') {
59
+ event.preventDefault()
60
+ cancelDeleteConfirm(id)
61
+ }
62
+ }
63
+
64
+ const {
65
+ quickReplies,
66
+ filtered,
67
+ isLoading,
68
+ loadError,
69
+ search,
70
+ setSearch,
71
+ readOnly,
72
+ canEdit,
73
+ canDelete,
74
+ editing,
75
+ startCreate,
76
+ startEdit,
77
+ cancelEdit,
78
+ updateField,
79
+ fieldErrors,
80
+ isSaving,
81
+ saveError,
82
+ submit,
83
+ remove,
84
+ deletingId,
85
+ hasAttachmentsCapability,
86
+ pendingUploads,
87
+ addAttachmentFiles,
88
+ retryAttachmentUpload,
89
+ cancelAttachmentUpload,
90
+ removeAttachment,
91
+ moveAttachmentAt,
92
+ attachmentRejections,
93
+ dismissAttachmentRejections,
94
+ } = useQuickRepliesWorkspace({ api, labels: text, ...(attachmentSizeLimits ? { attachmentSizeLimits } : {}) })
95
+
96
+ const hasPendingUploads = pendingUploads.length > 0
97
+
98
+ const insertVariableAtCursor = (marker: string) => {
99
+ const field = bodyFieldRef.current
100
+ if (!field || !editing) return
101
+ const { text: nextBody, caret } = insertAtCursor(editing.body, field.selectionStart, field.selectionEnd, marker)
102
+ updateField('body', nextBody)
103
+ requestAnimationFrame(() => {
104
+ field.focus()
105
+ field.setSelectionRange(caret, caret)
106
+ })
107
+ }
108
+
109
+ return (
110
+ <div className={cn('space-y-4', className)}>
111
+ <header className="space-y-0.5">
112
+ <h2 className="text-lg font-semibold">{text.title}</h2>
113
+ {/* Total cadastrado, nunca o filtrado — a busca não deve fazer parecer que sumiram
114
+ mensagens; a contagem de resultado da busca fica em `noResults`/`empty`. */}
115
+ <p className="text-sm text-gray-500 dark:text-gray-400">{text.subtitle(quickReplies.length)}</p>
116
+ </header>
117
+
118
+ {readOnly ? <p className="text-sm text-gray-500 dark:text-gray-400">{text.readOnlyNotice}</p> : null}
119
+
120
+ <div className="flex flex-wrap items-center gap-2">
121
+ <input
122
+ type="search"
123
+ value={search}
124
+ onChange={(event) => setSearch(event.target.value)}
125
+ placeholder={text.searchPlaceholder}
126
+ aria-label={text.searchPlaceholder}
127
+ className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-900 sm:w-64"
128
+ />
129
+
130
+ {!readOnly ? (
131
+ <button
132
+ type="button"
133
+ onClick={startCreate}
134
+ className="cv-header-action ml-auto inline-flex items-center gap-1"
135
+ >
136
+ {text.create}
137
+ </button>
138
+ ) : null}
139
+ </div>
140
+
141
+ {isLoading ? (
142
+ <span className="sr-only" aria-live="polite">
143
+ {text.loading}
144
+ </span>
145
+ ) : null}
146
+ {loadError ? (
147
+ <p role="alert" className="text-sm text-red-600 dark:text-red-400">
148
+ {loadError || text.failure}
149
+ </p>
150
+ ) : null}
151
+
152
+ {editing ? (
153
+ <form
154
+ className="space-y-3 rounded-lg border border-gray-200 p-4 dark:border-gray-700"
155
+ onSubmit={(event) => {
156
+ event.preventDefault()
157
+ void submit()
158
+ }}
159
+ >
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>
180
+
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>
205
+
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>
242
+
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}
257
+
258
+ {saveError ? (
259
+ <p role="alert" className="text-sm text-red-600 dark:text-red-400">
260
+ {saveError}
261
+ </p>
262
+ ) : null}
263
+
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>
277
+ </div>
278
+ </form>
279
+ ) : null}
280
+
281
+ <div className="cv-table-card">
282
+ <table className="cv-table">
283
+ <thead>
284
+ <tr>
285
+ <th scope="col" className="px-3 py-2 text-left text-xs font-medium">
286
+ {text.columnTitle}
287
+ </th>
288
+ <th scope="col" className="px-3 py-2 text-left text-xs font-medium">
289
+ {text.columnShortcut}
290
+ </th>
291
+ <th scope="col" className="hidden px-3 py-2 text-left text-xs font-medium sm:table-cell">
292
+ {text.columnBody}
293
+ </th>
294
+ {!readOnly ? (
295
+ <th scope="col" className="px-3 py-2 text-left text-xs font-medium">
296
+ {text.columnActions}
297
+ </th>
298
+ ) : null}
299
+ </tr>
300
+ </thead>
301
+ <tbody>
302
+ {isLoading ? (
303
+ Array.from({ length: TABLE_SKELETON_ROWS }).map((_, index) => (
304
+ <tr key={index} aria-hidden="true">
305
+ <td className="px-3 py-3">
306
+ <span className="cv-skeleton-line cv-skeleton-line--title block" />
307
+ </td>
308
+ <td className="px-3 py-3">
309
+ <span className="cv-skeleton-line cv-skeleton-line--shortcut block" />
310
+ </td>
311
+ <td className="hidden px-3 py-3 sm:table-cell">
312
+ <span className="cv-skeleton-line cv-skeleton-line--body block" />
313
+ </td>
314
+ {!readOnly ? <td className="px-3 py-3" /> : null}
315
+ </tr>
316
+ ))
317
+ ) : filtered.length === 0 ? (
318
+ <tr>
319
+ <td colSpan={4} className="px-3 py-4 text-center text-sm text-gray-500 dark:text-gray-400">
320
+ {search.trim() ? text.noResults : text.empty}
321
+ </td>
322
+ </tr>
323
+ ) : null}
324
+ {!isLoading &&
325
+ filtered.map((quickReply) => (
326
+ <tr key={quickReply.id} className={cn(deletingId === quickReply.id ? 'cv-row-departing' : undefined)}>
327
+ <td className="px-3 py-2 font-medium">{quickReply.title}</td>
328
+ <td className="px-3 py-2 font-mono text-xs text-gray-500">/{quickReply.shortcut}</td>
329
+ <td className="hidden max-w-sm truncate px-3 py-2 text-gray-500 sm:table-cell">{quickReply.body}</td>
330
+ {!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"
350
+ >
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"
360
+ >
361
+ {text.remove}
362
+ </button>
363
+ <button
364
+ type="button"
365
+ onClick={() => cancelDeleteConfirm(quickReply.id)}
366
+ className="text-gray-500 hover:underline dark:text-gray-400"
367
+ >
368
+ {text.cancel}
369
+ </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}
386
+ </td>
387
+ ) : null}
388
+ </tr>
389
+ ))}
390
+ </tbody>
391
+ </table>
392
+ </div>
393
+ </div>
394
+ )
395
+ }