@adatechnology/conversations-ui 0.1.0-rc.26 → 0.1.0-rc.28

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 (81) hide show
  1. package/dist/chunk-DXPSPUWF.js +110 -0
  2. package/dist/{chunk-TV4OQRGH.js → chunk-UZJBYD5O.js} +123 -45
  3. package/dist/{chunk-2AYDBWNE.js → chunk-WCBDXZ3X.js} +13 -3
  4. package/dist/flows/index.d.ts +75 -2
  5. package/dist/flows/index.js +176 -95
  6. package/dist/index.d.ts +37 -10
  7. package/dist/index.js +694 -297
  8. package/dist/preview/index.js +3 -3
  9. package/dist/styles.css +119 -6
  10. package/package.json +2 -2
  11. package/src/AudioPlayer.tsx +8 -0
  12. package/src/AudioRecorderButton.test.tsx +3 -3
  13. package/src/AudioRecorderButton.tsx +3 -3
  14. package/src/AudioTranscription.tsx +4 -1
  15. package/src/Avatar.tsx +1 -1
  16. package/src/ConversationContextPanel.tsx +2 -2
  17. package/src/ConversationDocumentsPanel.tsx +11 -6
  18. package/src/ConversationHeader.test.tsx +66 -0
  19. package/src/ConversationHeader.tsx +147 -47
  20. package/src/ConversationListItem.tsx +3 -2
  21. package/src/ConversationRow.tsx +31 -7
  22. package/src/DocumentsLibrary.tsx +9 -4
  23. package/src/EmojiPicker.tsx +2 -1
  24. package/src/InteractiveMessage.tsx +3 -0
  25. package/src/Lightbox.tsx +1 -1
  26. package/src/MediaRenderer.tsx +2 -0
  27. package/src/MessageBubble.test.tsx +41 -0
  28. package/src/MessageBubble.tsx +27 -8
  29. package/src/MessageComposer.tsx +11 -8
  30. package/src/RichMessageComposer.test.tsx +42 -12
  31. package/src/RichMessageComposer.tsx +216 -45
  32. package/src/SimpleEmojiPicker.tsx +5 -3
  33. package/src/StatusTicks.tsx +1 -1
  34. package/src/Toast.tsx +4 -0
  35. package/src/Tooltip.test.ts +42 -0
  36. package/src/Tooltip.tsx +164 -0
  37. package/src/WhatsAppMessageEditor.tsx +4 -4
  38. package/src/WindowExpiredNotice.tsx +12 -4
  39. package/src/buildOutput.test.ts +79 -0
  40. package/src/composer.constant.ts +33 -0
  41. package/src/conversationWindow.ts +7 -5
  42. package/src/documents/DocumentsWorkspace.tsx +10 -6
  43. package/src/flows/FlowGroupHeader.tsx +2 -2
  44. package/src/flows/FlowMapNode.tsx +2 -1
  45. package/src/flows/FlowNodeCard.tsx +3 -3
  46. package/src/flows/FlowNodePanel.tsx +6 -5
  47. package/src/flows/FlowPalette.tsx +7 -1
  48. package/src/flows/FlowPortalNode.tsx +1 -1
  49. package/src/flows/FlowsWorkspace.tsx +69 -102
  50. package/src/flows/flowEditorOps.test.ts +241 -0
  51. package/src/flows/flowEditorOps.ts +177 -0
  52. package/src/flows/flowGraph.ts +1 -1
  53. package/src/flows/index.ts +16 -0
  54. package/src/flows/labels.ts +4 -0
  55. package/src/flows/workspaceContract.test.ts +95 -0
  56. package/src/hooks/useContainerWidth.ts +35 -0
  57. package/src/hooks/useConversationRealtime.ts +10 -8
  58. package/src/icon.constant.ts +12 -0
  59. package/src/index.ts +1 -0
  60. package/src/lib/composer-formatting.test.ts +78 -0
  61. package/src/lib/composer-formatting.ts +145 -0
  62. package/src/lib/whatsapp-formatting.test.tsx +37 -0
  63. package/src/lib/whatsapp-formatting.tsx +28 -3
  64. package/src/listing/index.tsx +5 -1
  65. package/src/pagination.constant.ts +10 -0
  66. package/src/preview/ConversationSimulatorPanel.tsx +1 -1
  67. package/src/providers/ConversationsProvider.tsx +8 -6
  68. package/src/settings/MessagesWorkspace.tsx +3 -0
  69. package/src/settings/TopicsForm.tsx +2 -0
  70. package/src/settings/TranscriptionSettingsForm.test.tsx +1 -1
  71. package/src/settings/TranscriptionSettingsForm.tsx +1 -0
  72. package/src/settings/WelcomeFarewellForm.tsx +1 -0
  73. package/src/settings/WhatsAppCreateTemplateForm.tsx +1 -0
  74. package/src/settings/WhatsAppTemplateSettingsForm.tsx +5 -2
  75. package/src/settings/WhatsAppTemplatesSettings.tsx +2 -0
  76. package/src/styles.css +100 -4
  77. package/src/workspace/BulkTemplateModal.tsx +4 -2
  78. package/src/workspace/ConversationPane.tsx +49 -19
  79. package/src/workspace/ConversationsInboxList.tsx +17 -8
  80. package/src/workspace/ConversationsWorkspace.tsx +24 -16
  81. package/src/workspace/useConversationsInbox.ts +5 -2
@@ -0,0 +1,37 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+
4
+ import { parseWhatsAppFormatting } from './whatsapp-formatting'
5
+
6
+ function render(text: string): string {
7
+ return renderToStaticMarkup(<>{parseWhatsAppFormatting(text)}</>)
8
+ }
9
+
10
+ describe('parseWhatsAppFormatting com marcador sem par', () => {
11
+ // Estes casos travavam a aba: o laço reprocessava a mesma string sem consumir o marcador.
12
+ it('trata o sublinhado solto de um nome de arquivo como texto', () => {
13
+ expect(render('IMG_2026.jpg')).toBe('<span>IMG</span><span>_</span><span>2026.jpg</span>')
14
+ })
15
+
16
+ it('não trava com asterisco, til ou crase sem fechamento', () => {
17
+ expect(render('3 * 4')).toContain('*')
18
+ expect(render('mais ou menos ~10')).toContain('~')
19
+ expect(render('use ` para código')).toContain('`')
20
+ })
21
+
22
+ it('sobrevive a uma linha só de marcadores', () => {
23
+ expect(render('*_~`')).toBe('<span>*</span><span>_</span><span>~</span><span>`</span>')
24
+ })
25
+ })
26
+
27
+ describe('parseWhatsAppFormatting com marcador fechado', () => {
28
+ it('continua formatando o par completo depois do marcador solto', () => {
29
+ expect(render('IMG_2026 *urgente*')).toContain('<strong>urgente</strong>')
30
+ })
31
+
32
+ it('mantém negrito, itálico e tachado', () => {
33
+ expect(render('*a* _b_ ~c~')).toBe(
34
+ '<strong>a</strong><span> </span><em>b</em><span> </span><del>c</del>',
35
+ )
36
+ })
37
+ })
@@ -90,9 +90,15 @@ function parseInlineTokens(text: string): FormatToken[] {
90
90
  if (remaining) result.push({ type: 'text', content: remaining })
91
91
  break
92
92
  }
93
- if (nextSpecial > 0) {
94
- result.push({ type: 'text', content: remaining.slice(0, nextSpecial) })
93
+ // Marcador sem par — o `_` de `IMG_2026.jpg` é o caso comum. Nenhuma das regras casou e o
94
+ // marcador está na posição zero: sem consumir esse caractere o laço reprocessa a mesma string
95
+ // para sempre e trava a aba inteira, porque isto roda no render de cada mensagem.
96
+ if (nextSpecial === 0) {
97
+ result.push({ type: 'text', content: remaining.slice(0, 1) })
98
+ remaining = remaining.slice(1)
99
+ continue
95
100
  }
101
+ result.push({ type: 'text', content: remaining.slice(0, nextSpecial) })
96
102
  remaining = remaining.slice(nextSpecial)
97
103
  }
98
104
 
@@ -137,6 +143,15 @@ function unescapeHtml(text: string): string {
137
143
  const CODE_TOKEN_MARK = String.fromCharCode(0xe000)
138
144
  const CODE_TOKEN_REGEX = new RegExp(`${CODE_TOKEN_MARK}(\\d+)${CODE_TOKEN_MARK}`, 'g')
139
145
 
146
+ /**
147
+ * Âncora invisível que o campo rico usa para pôr o cursor dentro (ou fora) de um `<code>` — não
148
+ * existe comando de navegador para monoespaçado, e o cursor sozinho não fica onde não há texto.
149
+ * Ela é do editor, nunca da mensagem: sai toda aqui, na conversão de volta.
150
+ */
151
+ export const ZERO_WIDTH_SPACE = String.fromCharCode(0x200b)
152
+ const EMPTY_CODE_REGEX = new RegExp(`<code[^>]*>[${ZERO_WIDTH_SPACE}\\s]*</code>`, 'gi')
153
+ const ZERO_WIDTH_SPACE_REGEX = new RegExp(ZERO_WIDTH_SPACE, 'g')
154
+
140
155
  // waToHTML/htmlToWA formam um par round-trip: todo texto que sai de waToHTML deve
141
156
  // reconstruir exatamente o original ao passar por htmlToWA (inclusive blocos de
142
157
  // código multi-linha, que exigem distinguir ``` de ` via atributo data-wa).
@@ -156,7 +171,10 @@ export function waToHTML(text: string): string {
156
171
  let html = escapeHtml(working)
157
172
  html = html.replace(/\*([^*\n]+)\*/g, '<strong>$1</strong>')
158
173
  html = html.replace(/_([^_\n]+)_/g, '<em>$1</em>')
159
- html = html.replace(/~([^~\n]+)~/g, '<del>$1</del>')
174
+ // `<s>` e não `<del>`: o navegador se recusa a tirar o tachado de um `<del>` — o botão acendia,
175
+ // o clique não fazia nada e o texto continuava riscado. Com `<s>` a alternância funciona, e a
176
+ // linha na tela é a mesma. `<del>` continua certo na exibição da mensagem, que não é editável.
177
+ html = html.replace(/~([^~\n]+)~/g, '<s>$1</s>')
160
178
  html = html.replace(/\n/g, '<br>')
161
179
 
162
180
  html = html.replace(CODE_TOKEN_REGEX, (_match, indexStr: string) => {
@@ -175,6 +193,9 @@ export function htmlToWA(html: string): string {
175
193
  if (!html) return ''
176
194
 
177
195
  let text = html
196
+ // O `<code>` que o cursor abre nasce só com a âncora de largura zero. Sem nada digitado dentro,
197
+ // ele viraria um par de crases vazias no texto enviado.
198
+ text = text.replace(EMPTY_CODE_REGEX, '')
178
199
  text = text.replace(/<code data-wa="block"[^>]*>([\s\S]*?)<\/code>/gi, (_match, inner: string) => (
179
200
  `\`\`\`${unescapeHtml(inner.replace(/<br\s*\/?>/gi, '\n'))}\`\`\``
180
201
  ))
@@ -195,11 +216,15 @@ export function htmlToWA(html: string): string {
195
216
  text = text.replace(/<i>(.*?)<\/i>/gi, '_$1_')
196
217
  text = text.replace(/<del>(.*?)<\/del>/gi, '~$1~')
197
218
  text = text.replace(/<s>(.*?)<\/s>/gi, '~$1~')
219
+ // O `strikeThrough` do navegador escreve `<strike>`, que não é o que geramos mas chega no campo.
220
+ text = text.replace(/<strike>(.*?)<\/strike>/gi, '~$1~')
198
221
  // Compat: HTML sem os marcadores data-wa (ex: vindo de outro editor) ainda vira código inline.
199
222
  text = text.replace(/<code[^>]*>(.*?)<\/code>/gi, '`$1`')
200
223
 
201
224
  text = text.replace(/<[^>]+>/g, '')
202
225
  text = unescapeHtml(text)
226
+ // A âncora do cursor sobrevive dentro do `<code>` que ganhou texto; ela é do editor, não da mensagem.
227
+ text = text.replace(ZERO_WIDTH_SPACE_REGEX, '')
203
228
  return text.trim()
204
229
  }
205
230
 
@@ -29,6 +29,7 @@ export function SortableHead({ label, field, activeField, direction, onSort, cla
29
29
  return (
30
30
  <th scope="col" className={cn('px-3 py-2 text-left text-xs font-medium', className)}>
31
31
  <button
32
+ data-cv-tooltip={label}
32
33
  type="button"
33
34
  onClick={() => onSort(field)}
34
35
  aria-label={label}
@@ -64,6 +65,7 @@ export function MultiSelectFilter({ label, options, selected, onChange, classNam
64
65
  return (
65
66
  <div className={cn('relative', className)}>
66
67
  <button
68
+ data-cv-tooltip={label} aria-label={label}
67
69
  type="button"
68
70
  onClick={() => setOpen((current) => !current)}
69
71
  className="cv-header-action inline-flex items-center gap-1"
@@ -112,7 +114,7 @@ export function BulkActionBar({ selectedCount, selectedLabel, clearLabel, onClea
112
114
  <div className="cv-bulk-bar" role="toolbar" aria-label={selectedLabel(selectedCount)}>
113
115
  <span className="text-xs font-medium">{selectedLabel(selectedCount)}</span>
114
116
  {children}
115
- <button type="button" onClick={onClear} className="cv-header-action ml-auto inline-flex items-center gap-1">
117
+ <button data-cv-tooltip={clearLabel} aria-label={clearLabel} type="button" onClick={onClear} className="cv-header-action ml-auto inline-flex items-center gap-1">
116
118
  <X size={12} aria-hidden="true" />
117
119
  {clearLabel}
118
120
  </button>
@@ -174,6 +176,7 @@ export function ListingPagination({
174
176
 
175
177
  <div className="flex items-center gap-2">
176
178
  <button
179
+ data-cv-tooltip={labels.previous}
177
180
  type="button"
178
181
  onClick={() => onPageChange(page - 1)}
179
182
  disabled={page <= 1}
@@ -184,6 +187,7 @@ export function ListingPagination({
184
187
  </button>
185
188
  <span className="text-xs text-gray-500">{labels.page(page, lastPage)}</span>
186
189
  <button
190
+ data-cv-tooltip={labels.next}
187
191
  type="button"
188
192
  onClick={() => onPageChange(page + 1)}
189
193
  disabled={page >= lastPage}
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Rótulos dos controles de paginação. Os pagers da inbox, do painel de documentos e da biblioteca
3
+ * são o mesmo controle repetido: o texto mora aqui para os três dizerem a mesma coisa.
4
+ */
5
+ export const PAGINATION_LABELS = {
6
+ first: 'Primeira página',
7
+ previous: 'Página anterior',
8
+ next: 'Próxima página',
9
+ last: 'Última página',
10
+ } as const
@@ -72,7 +72,7 @@ export function ConversationSimulatorPanel({
72
72
  </div>
73
73
  <div className="cv-simulator-panel__actions">
74
74
  {headerActions}
75
- <button type="button" onClick={onClose} title={text.close} aria-label={text.close} className="cv-simulator-panel__close">
75
+ <button type="button" onClick={onClose} data-cv-tooltip={text.close} aria-label={text.close} className="cv-simulator-panel__close">
76
76
  {/* SVG inline em vez de lucide-react: o pacote não impõe biblioteca de ícone ao host. */}
77
77
  <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
78
78
  <path d="M18 6 6 18M6 6l12 12" />
@@ -1,4 +1,4 @@
1
- import { createContext, useContext, type ReactNode } from 'react'
1
+ import { createContext, useContext, useMemo, type ReactNode } from 'react'
2
2
  import type { ConversationsApi, SSEProvider } from './types'
3
3
 
4
4
  interface ConversationsContextValue {
@@ -17,11 +17,13 @@ export function ConversationsProvider({
17
17
  sse: SSEProvider
18
18
  children: ReactNode
19
19
  }) {
20
- return (
21
- <ConversationsContext.Provider value={{ api, sse }}>
22
- {children}
23
- </ConversationsContext.Provider>
24
- )
20
+ // Objeto novo a cada render fazia todo efeito que depende do contexto reexecutar junto — o do
21
+ // `useConversationRealtime` fecha e reabre o SSE da conversa, e cada reabertura pede um ticket
22
+ // novo. Uma tela que renderiza em rajada saturava as 6 conexões do navegador com requisições
23
+ // pendentes que nunca chegavam a servir para nada.
24
+ const value = useMemo(() => ({ api, sse }), [api, sse])
25
+
26
+ return <ConversationsContext.Provider value={value}>{children}</ConversationsContext.Provider>
25
27
  }
26
28
 
27
29
  export function useConversations(): ConversationsContextValue | null {
@@ -33,6 +33,7 @@ import {
33
33
  type TranscriptionSettingsFormLabels,
34
34
  } from './TranscriptionSettingsForm'
35
35
  import type { TranscriptionMode } from '../types'
36
+ import { TooltipLayer } from '../Tooltip'
36
37
 
37
38
  export interface BotMessages {
38
39
  welcomeMessage: string
@@ -345,6 +346,7 @@ export function MessagesWorkspace({
345
346
 
346
347
  return (
347
348
  <div className={`space-y-6 p-4 lg:p-0 ${className ?? ''}`}>
349
+ <TooltipLayer />
348
350
  <header>
349
351
  <h1 className="text-xl font-semibold text-gray-900 dark:text-gray-100">{labels.title}</h1>
350
352
  <p className="text-sm text-gray-500 dark:text-gray-400">{labels.subtitle}</p>
@@ -355,6 +357,7 @@ export function MessagesWorkspace({
355
357
  <nav className="flex gap-1 border-b border-gray-200 dark:border-gray-700" role="tablist">
356
358
  {tabs.map((item) => (
357
359
  <button
360
+ data-cv-tooltip={item.label} aria-label={item.label}
358
361
  key={item.id}
359
362
  type="button"
360
363
  role="tab"
@@ -66,6 +66,7 @@ export function TopicsForm({
66
66
  <p className="text-sm font-medium text-gray-900 dark:text-gray-100">{topic.label}</p>
67
67
  </div>
68
68
  <button
69
+ data-cv-tooltip={topic.label} aria-label={topic.label}
69
70
  type="button"
70
71
  onClick={() => onToggle(topic.key)}
71
72
  className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none ${
@@ -94,6 +95,7 @@ export function TopicsForm({
94
95
 
95
96
  <div className="flex items-center gap-3">
96
97
  <button
98
+ data-cv-tooltip={labels.saveButton} aria-label={labels.saveButton}
97
99
  type="submit"
98
100
  disabled={saving}
99
101
  className="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
@@ -76,6 +76,6 @@ describe('TranscriptionSettingsForm', () => {
76
76
  const markup = render({ saving: true })
77
77
 
78
78
  expect(markup).toContain('Salvando...')
79
- expect(findTag(markup, /<button type="submit"[^>]*>/g, 'type="submit"')).toContain('disabled')
79
+ expect(findTag(markup, /<button[^>]*>/g, 'type="submit"')).toContain('disabled')
80
80
  })
81
81
  })
@@ -132,6 +132,7 @@ export function TranscriptionSettingsForm({
132
132
 
133
133
  <div className="flex justify-end">
134
134
  <button
135
+ data-cv-tooltip={saving ? labels.saving : labels.save} aria-label={saving ? labels.saving : labels.save}
135
136
  type="submit"
136
137
  disabled={saving || !isAvailable}
137
138
  className="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-semibold px-5 py-2.5 rounded-xl transition-colors text-sm"
@@ -104,6 +104,7 @@ export function WelcomeFarewellForm({
104
104
 
105
105
  <div className="flex justify-end">
106
106
  <button
107
+ data-cv-tooltip={saving ? labels.saving : labels.save} aria-label={saving ? labels.saving : labels.save}
107
108
  type="submit"
108
109
  disabled={saving}
109
110
  className="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-semibold px-5 py-2.5 rounded-xl transition-colors text-sm"
@@ -297,6 +297,7 @@ export function WhatsAppCreateTemplateForm({
297
297
 
298
298
  <div className="flex justify-end">
299
299
  <button
300
+ data-cv-tooltip={submitting ? labels.sending : labels.sendForApproval} aria-label={submitting ? labels.sending : labels.sendForApproval}
300
301
  type="submit"
301
302
  disabled={submitting || !value.name || !value.bodyText}
302
303
  className="flex items-center gap-2 bg-emerald-600 hover:bg-emerald-700 disabled:bg-emerald-400 text-white font-semibold px-5 py-2.5 rounded-xl transition-colors text-sm"
@@ -132,6 +132,7 @@ export function WhatsAppTemplateSettingsForm({
132
132
  <div className="flex items-center justify-between mb-1.5">
133
133
  <label className="text-sm font-medium text-gray-700 dark:text-gray-300">{labels.templateLabel}</label>
134
134
  <button
135
+ data-cv-tooltip={labels.refreshList} aria-label={labels.refreshList}
135
136
  type="button"
136
137
  onClick={onRefreshTemplates}
137
138
  disabled={loadingTemplates}
@@ -180,6 +181,7 @@ export function WhatsAppTemplateSettingsForm({
180
181
  <div className="flex items-center justify-between mb-1.5">
181
182
  <label className="text-sm font-medium text-gray-700 dark:text-gray-300">{labels.variablesLabel}</label>
182
183
  <button
184
+ data-cv-tooltip={labels.addVariable} aria-label={labels.addVariable}
183
185
  type="button"
184
186
  onClick={() => onVariablesChange([...variables, ''])}
185
187
  className="flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400 hover:underline"
@@ -198,7 +200,7 @@ export function WhatsAppTemplateSettingsForm({
198
200
  key={available.token}
199
201
  type="button"
200
202
  onClick={() => insertVariable(available.token)}
201
- title={`Inserir ${available.token}`}
203
+ data-cv-tooltip={`Inserir ${available.token}`} aria-label={`Inserir ${available.token}`}
202
204
  className="inline-flex items-center gap-1 rounded-md border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-800 px-2 py-1 text-xs hover:border-blue-400 dark:hover:border-blue-500 transition-colors"
203
205
  >
204
206
  <code className="text-blue-600 dark:text-blue-300">{available.token}</code>
@@ -233,7 +235,7 @@ export function WhatsAppTemplateSettingsForm({
233
235
  type="button"
234
236
  onClick={() => removeVariable(index)}
235
237
  className="text-gray-400 hover:text-red-500 dark:hover:text-red-400 transition-colors flex-shrink-0"
236
- title={labels.removeVariable}
238
+ data-cv-tooltip={labels.removeVariable} aria-label={labels.removeVariable}
237
239
  >
238
240
  <Trash2 size={14} />
239
241
  </button>
@@ -250,6 +252,7 @@ export function WhatsAppTemplateSettingsForm({
250
252
 
251
253
  <div className="flex justify-end">
252
254
  <button
255
+ data-cv-tooltip={saving ? labels.saving : labels.save} aria-label={saving ? labels.saving : labels.save}
253
256
  type="submit"
254
257
  disabled={saving || !selectedTemplateName.trim()}
255
258
  className="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-semibold px-5 py-2.5 rounded-xl transition-colors text-sm"
@@ -78,6 +78,7 @@ export function WhatsAppTemplatesSettings({
78
78
  <div className="space-y-4">
79
79
  <nav className="flex gap-1 border-b" role="tablist">
80
80
  <button
81
+ data-cv-tooltip={labels.selectTab} aria-label={labels.selectTab}
81
82
  type="button"
82
83
  role="tab"
83
84
  aria-selected={tab === TEMPLATE_SETTINGS_TAB.SELECT}
@@ -91,6 +92,7 @@ export function WhatsAppTemplatesSettings({
91
92
  que não leva a nada. */}
92
93
  {create ? (
93
94
  <button
95
+ data-cv-tooltip={labels.createTab} aria-label={labels.createTab}
94
96
  type="button"
95
97
  role="tab"
96
98
  aria-selected={tab === TEMPLATE_SETTINGS_TAB.CREATE}
package/src/styles.css CHANGED
@@ -22,7 +22,12 @@
22
22
 
23
23
  /* Botão de ação do cabeçalho de conversa e do aviso de janela. Vive no stylesheet, como o
24
24
  .cv-wallpaper, para não depender da configuração de Tailwind do consumidor. */
25
+ /* `inline-flex` com gap em todo controle com ícone: o ícone é SVG e, como elemento inline, assentava
26
+ na baseline do texto — o traço ficava um fio abaixo do rótulo em cada botão. */
25
27
  .cv-header-action {
28
+ display: inline-flex;
29
+ align-items: center;
30
+ gap: 0.375rem;
26
31
  border: 1px solid rgb(203 213 225);
27
32
  border-radius: 0.375rem;
28
33
  padding: 0.375rem 0.75rem;
@@ -34,6 +39,9 @@
34
39
  .dark .cv-header-action { border-color: rgb(71 85 105); color: rgb(226 232 240); }
35
40
 
36
41
  .cv-header-icon {
42
+ display: inline-flex;
43
+ align-items: center;
44
+ justify-content: center;
37
45
  border: 1px solid rgb(203 213 225);
38
46
  border-radius: 0.5rem;
39
47
  padding: 0.375rem 0.5rem;
@@ -100,6 +108,9 @@
100
108
 
101
109
  /* Voltar do master/detail: existe só onde lista e conversa não cabem lado a lado. */
102
110
  .cv-back {
111
+ display: inline-flex;
112
+ align-items: center;
113
+ justify-content: center;
103
114
  border: 1px solid rgb(203 213 225);
104
115
  border-radius: 0.5rem;
105
116
  padding: 0.25rem 0.5rem;
@@ -110,6 +121,13 @@
110
121
  @media (min-width: 1024px) { .cv-back { display: none; } }
111
122
  .dark .cv-back { border-color: rgb(71 85 105); color: rgb(226 232 240); }
112
123
 
124
+ /* Contador do cabeçalho da inbox: ícone e número na mesma linha de base ótica. */
125
+ .cv-stat {
126
+ display: inline-flex;
127
+ align-items: center;
128
+ gap: 0.25rem;
129
+ }
130
+
113
131
  /* Utilitários de densidade: em tela estreita o texto das ações vira ícone, porque rótulo longo
114
132
  empilha e come a altura útil da conversa. */
115
133
  .cv-only-wide { display: none; }
@@ -328,6 +346,9 @@
328
346
  }
329
347
 
330
348
  .cv-workspace-toggle {
349
+ display: inline-flex;
350
+ align-items: center;
351
+ gap: 0.375rem;
331
352
  border: 1px solid rgb(229 231 235);
332
353
  border-radius: 0.375rem;
333
354
  padding: 0.5rem 0.75rem;
@@ -495,24 +516,48 @@
495
516
  }
496
517
  .dark .cv-workspace-search { border-color: rgb(71 85 105); color: rgb(226 232 240); }
497
518
 
519
+ /* Linha única com rolagem, e não wrap: quebrando, a fila de janelas empurrava a categoria e a
520
+ lista para baixo toda vez que a coluna estreitava. */
498
521
  .cv-workspace-chips {
499
522
  display: flex;
500
- flex-wrap: wrap;
523
+ flex-wrap: nowrap;
501
524
  align-items: center;
502
- gap: 0.5rem;
525
+ gap: 0.375rem;
503
526
  font-size: 0.75rem;
527
+ overflow-x: auto;
528
+ scrollbar-width: none;
504
529
  }
505
- .cv-workspace-chips__legend { color: rgb(107 114 128); }
530
+ .cv-workspace-chips::-webkit-scrollbar { display: none; }
531
+ .cv-workspace-chips__legend { color: rgb(107 114 128); flex-shrink: 0; }
506
532
  .cv-workspace-chip {
507
533
  display: flex;
534
+ flex-shrink: 0;
508
535
  align-items: center;
509
536
  gap: 0.25rem;
537
+ white-space: nowrap;
510
538
  border-radius: 0.375rem;
511
539
  padding: 0.25rem 0.5rem;
512
540
  }
513
541
  .cv-workspace-chip--on { background: rgb(229 231 235); }
514
542
  .dark .cv-workspace-chip--on { background: rgb(51 65 85); }
515
- .cv-workspace-dot { width: 0.5rem; height: 0.5rem; border-radius: 9999px; }
543
+
544
+ /* A cor do próprio rótulo é o estado da janela; selecionado, o mesmo tom vira o fundo. */
545
+ .cv-workspace-chip--fresh { color: rgb(22 163 74); }
546
+ .cv-workspace-chip--warning { color: rgb(202 138 4); }
547
+ .cv-workspace-chip--critical { color: rgb(220 38 38); }
548
+ .cv-workspace-chip--expired { color: rgb(107 114 128); }
549
+ .cv-workspace-chip--fresh.cv-workspace-chip--on { background: rgb(220 252 231); }
550
+ .cv-workspace-chip--warning.cv-workspace-chip--on { background: rgb(254 249 195); }
551
+ .cv-workspace-chip--critical.cv-workspace-chip--on { background: rgb(254 226 226); }
552
+ .cv-workspace-chip--expired.cv-workspace-chip--on { background: rgb(229 231 235); }
553
+ .dark .cv-workspace-chip--fresh { color: rgb(74 222 128); }
554
+ .dark .cv-workspace-chip--warning { color: rgb(250 204 21); }
555
+ .dark .cv-workspace-chip--critical { color: rgb(248 113 113); }
556
+ .dark .cv-workspace-chip--expired { color: rgb(148 163 184); }
557
+ .dark .cv-workspace-chip--fresh.cv-workspace-chip--on { background: rgb(20 83 45); }
558
+ .dark .cv-workspace-chip--warning.cv-workspace-chip--on { background: rgb(113 63 18); }
559
+ .dark .cv-workspace-chip--critical.cv-workspace-chip--on { background: rgb(127 29 29); }
560
+ .dark .cv-workspace-chip--expired.cv-workspace-chip--on { background: rgb(51 65 85); }
516
561
 
517
562
  .cv-workspace-selectall {
518
563
  display: flex;
@@ -842,3 +887,54 @@
842
887
  font-size: 0.75rem;
843
888
  }
844
889
  .dark .cv-listing-perpage { border-color: rgb(71 85 105); }
890
+
891
+ /* Dica de ação (ver `Tooltip.tsx`). Impressa em `document.body`, então a posição é da janela e o
892
+ `z-index` fica acima de qualquer painel do produto. */
893
+ .cv-tooltip {
894
+ position: fixed;
895
+ z-index: 9999;
896
+ max-width: min(16rem, 90vw);
897
+ transform: translate(-50%, -100%);
898
+ border-radius: 0.375rem;
899
+ background: rgb(17 24 39 / 95%);
900
+ padding: 0.3125rem 0.5rem;
901
+ font-size: 0.75rem;
902
+ line-height: 1.2;
903
+ color: #fff;
904
+ text-align: center;
905
+ box-shadow: 0 4px 12px rgb(0 0 0 / 18%);
906
+ pointer-events: none;
907
+ animation: cv-tooltip-in 90ms ease-out;
908
+ }
909
+
910
+ .cv-tooltip--bottom {
911
+ transform: translate(-50%, 0);
912
+ }
913
+
914
+ .dark .cv-tooltip {
915
+ background: rgb(243 244 246 / 96%);
916
+ color: rgb(17 24 39);
917
+ }
918
+
919
+ @keyframes cv-tooltip-in {
920
+ from { opacity: 0; }
921
+ to { opacity: 1; }
922
+ }
923
+
924
+ @media (prefers-reduced-motion: reduce) {
925
+ .cv-tooltip { animation: none; }
926
+ }
927
+
928
+ /* Barra de composição: fundo neutro para separar da conversa e para os atalhos e o campo de texto
929
+ terem contraste próprio. Sobre branco, régua branca e campo cinza-claro se confundiam num bloco
930
+ só, sem hierarquia. */
931
+ .cv-composer-bar {
932
+ border-top: 1px solid rgb(229 231 235);
933
+ background: rgb(240 242 245);
934
+ padding: 0.625rem 0.75rem;
935
+ }
936
+
937
+ .dark .cv-composer-bar {
938
+ border-top-color: rgb(51 65 85);
939
+ background: rgb(17 24 39);
940
+ }
@@ -70,7 +70,7 @@ export function BulkTemplateModal({
70
70
  <h3>{labels.templateModalTitle}</h3>
71
71
  <p>{labels.templateModalAvailable(templates.length)}</p>
72
72
  </div>
73
- <button type="button" onClick={onClose} aria-label={labels.templateModalCancel}>
73
+ <button data-cv-tooltip={labels.templateModalCancel} type="button" onClick={onClose} aria-label={labels.templateModalCancel}>
74
74
  ✕
75
75
  </button>
76
76
  </header>
@@ -89,6 +89,7 @@ export function BulkTemplateModal({
89
89
  {filtered.length === 0 ? <p className="cv-workspace-empty">{labels.templateModalEmpty}</p> : null}
90
90
  {filtered.map((template) => (
91
91
  <button
92
+ data-cv-tooltip={template.name} aria-label={template.name}
92
93
  key={template.name}
93
94
  type="button"
94
95
  // Reclicar desmarca: sem isso não havia como voltar ao template padrão depois de
@@ -113,10 +114,11 @@ export function BulkTemplateModal({
113
114
  ) : null}
114
115
 
115
116
  <footer className="cv-workspace-modal__footer">
116
- <button type="button" onClick={onClose}>
117
+ <button data-cv-tooltip={labels.templateModalCancel} aria-label={labels.templateModalCancel} type="button" onClick={onClose}>
117
118
  {labels.templateModalCancel}
118
119
  </button>
119
120
  <button
121
+ data-cv-tooltip={sending ? labels.templateModalSending : labels.templateModalSend(expiredCount)} aria-label={sending ? labels.templateModalSending : labels.templateModalSend(expiredCount)}
120
122
  type="button"
121
123
  disabled={sending || expiredCount === 0}
122
124
  onClick={() => onSend(selected || undefined)}