@adatechnology/conversations-ui 0.1.0-rc.9 → 0.1.1

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 (129) hide show
  1. package/dist/ConversationSimulatorPanel--5fIzXWY.d.ts +804 -0
  2. package/dist/{chunk-G5BM3VBP.js → chunk-BJNRLLDO.js} +1248 -282
  3. package/dist/chunk-DKPXKQGC.js +110 -0
  4. package/dist/{chunk-2AYDBWNE.js → chunk-WCBDXZ3X.js} +13 -3
  5. package/dist/flows/index.d.ts +422 -5
  6. package/dist/flows/index.js +2502 -676
  7. package/dist/index.d.ts +919 -17
  8. package/dist/index.js +3676 -675
  9. package/dist/preview/index.d.ts +62 -105
  10. package/dist/preview/index.js +162 -284
  11. package/dist/styles.css +893 -0
  12. package/package.json +9 -8
  13. package/src/AudioPlayer.tsx +8 -0
  14. package/src/AudioRecorderButton.test.tsx +30 -0
  15. package/src/AudioRecorderButton.tsx +248 -0
  16. package/src/AudioTranscription.test.tsx +115 -0
  17. package/src/AudioTranscription.tsx +252 -0
  18. package/src/Avatar.tsx +1 -1
  19. package/src/ConversationContextPanel.tsx +218 -44
  20. package/src/ConversationDocumentsPanel.tsx +11 -6
  21. package/src/ConversationHeader.test.tsx +66 -0
  22. package/src/ConversationHeader.tsx +147 -47
  23. package/src/ConversationListItem.tsx +8 -6
  24. package/src/ConversationLocalesProvider.tsx +28 -0
  25. package/src/ConversationRow.tsx +53 -7
  26. package/src/DarkModeToggle.test.tsx +76 -0
  27. package/src/DarkModeToggle.tsx +92 -0
  28. package/src/DocumentsLibrary.tsx +67 -7
  29. package/src/EmojiPicker.tsx +2 -1
  30. package/src/InteractiveMessage.tsx +3 -0
  31. package/src/Lightbox.tsx +1 -1
  32. package/src/MediaRenderer.tsx +88 -15
  33. package/src/MessageBubble.test.tsx +41 -0
  34. package/src/MessageBubble.tsx +47 -5
  35. package/src/MessageComposer.test.tsx +35 -0
  36. package/src/MessageComposer.tsx +122 -17
  37. package/src/MessageText.tsx +2 -1
  38. package/src/MessageTimestamp.tsx +2 -1
  39. package/src/RichMessageComposer.test.tsx +113 -0
  40. package/src/RichMessageComposer.tsx +551 -0
  41. package/src/SimpleEmojiPicker.tsx +5 -3
  42. package/src/StatusTicks.tsx +1 -1
  43. package/src/Toast.tsx +4 -0
  44. package/src/Tooltip.test.ts +42 -0
  45. package/src/Tooltip.tsx +167 -0
  46. package/src/Wallpaper.tsx +27 -13
  47. package/src/WhatsAppMessageEditor.tsx +10 -7
  48. package/src/WindowExpiredNotice.tsx +12 -4
  49. package/src/{preview/audioRecorderFormat.test.ts → audioRecorderFormat.test.ts} +1 -1
  50. package/src/buildOutput.test.ts +79 -0
  51. package/src/composer.constant.ts +33 -0
  52. package/src/conversationTranscript.test.ts +57 -0
  53. package/src/conversationTranscript.ts +29 -4
  54. package/src/conversationWindow.ts +7 -5
  55. package/src/documentTypeLabel.test.ts +57 -0
  56. package/src/documents/DocumentsWorkspace.tsx +550 -0
  57. package/src/documents/index.ts +8 -0
  58. package/src/documents/labels.ts +92 -0
  59. package/src/flows/FlowConnectionEdge.tsx +104 -0
  60. package/src/flows/FlowGroupHeader.tsx +12 -2
  61. package/src/flows/FlowLegend.tsx +125 -0
  62. package/src/flows/FlowMapCanvas.tsx +15 -12
  63. package/src/flows/FlowMapNode.tsx +4 -1
  64. package/src/flows/FlowNodeCard.tsx +219 -34
  65. package/src/flows/FlowNodePanel.tsx +153 -39
  66. package/src/flows/FlowPalette.tsx +156 -70
  67. package/src/flows/FlowPortalNode.tsx +1 -1
  68. package/src/flows/FlowWhatsAppPreview.tsx +14 -3
  69. package/src/flows/FlowsWorkspace.tsx +1255 -0
  70. package/src/flows/flowCanvasModel.test.ts +456 -0
  71. package/src/flows/flowCanvasModel.ts +378 -0
  72. package/src/flows/flowEditorOps.test.ts +276 -0
  73. package/src/flows/flowEditorOps.ts +202 -0
  74. package/src/flows/flowGraph.ts +78 -53
  75. package/src/flows/flowMenuPlacement.test.ts +130 -0
  76. package/src/flows/flowMenuPlacement.ts +86 -0
  77. package/src/flows/index.ts +51 -2
  78. package/src/flows/labels.ts +180 -0
  79. package/src/flows/workspaceContract.test.ts +126 -0
  80. package/src/hooks/useContainerWidth.ts +35 -0
  81. package/src/hooks/useConversationRealtime.ts +10 -8
  82. package/src/hooks/useScrollToLatestMessage.ts +127 -0
  83. package/src/hooks/useUrlFilterState.ts +107 -0
  84. package/src/icon.constant.ts +12 -0
  85. package/src/index.ts +100 -0
  86. package/src/lib/composer-formatting.test.ts +78 -0
  87. package/src/lib/composer-formatting.ts +145 -0
  88. package/src/lib/whatsapp-formatting.test.tsx +37 -0
  89. package/src/lib/whatsapp-formatting.tsx +28 -3
  90. package/src/listing/index.tsx +202 -0
  91. package/src/pagination.constant.ts +10 -0
  92. package/src/preview/ConversationPreview.tsx +84 -45
  93. package/src/preview/ConversationSimulatorClient.ts +143 -0
  94. package/src/preview/ConversationSimulatorPanel.test.tsx +55 -0
  95. package/src/preview/ConversationSimulatorPanel.tsx +131 -0
  96. package/src/preview/createPreviewBridgeClient.test.ts +92 -0
  97. package/src/preview/createPreviewBridgeClient.ts +124 -0
  98. package/src/preview/createPreviewMediaUploader.ts +82 -0
  99. package/src/preview/createPreviewWebhookClient.test.ts +96 -0
  100. package/src/preview/createPreviewWebhookClient.ts +99 -3
  101. package/src/preview/index.ts +36 -2
  102. package/src/preview/previewMediaUploader.test.ts +61 -0
  103. package/src/providers/ConversationsProvider.tsx +8 -6
  104. package/src/providers/types.ts +59 -2
  105. package/src/quickReply.test.ts +58 -0
  106. package/src/replyLatency.test.ts +71 -0
  107. package/src/replyLatency.ts +57 -0
  108. package/src/settings/MessagesWorkspace.tsx +571 -0
  109. package/src/settings/TopicsForm.tsx +2 -0
  110. package/src/settings/TranscriptionSettingsForm.test.tsx +81 -0
  111. package/src/settings/TranscriptionSettingsForm.tsx +190 -0
  112. package/src/settings/WelcomeFarewellForm.tsx +1 -0
  113. package/src/settings/WhatsAppCreateTemplateForm.tsx +1 -0
  114. package/src/settings/WhatsAppTemplateSettingsForm.tsx +5 -2
  115. package/src/settings/WhatsAppTemplatesSettings.test.tsx +61 -0
  116. package/src/settings/WhatsAppTemplatesSettings.tsx +22 -2
  117. package/src/styles.css +858 -0
  118. package/src/theme.ts +13 -0
  119. package/src/types.ts +26 -0
  120. package/src/workspace/BulkTemplateModal.tsx +132 -0
  121. package/src/workspace/ConversationPane.tsx +432 -0
  122. package/src/workspace/ConversationsInboxList.tsx +194 -0
  123. package/src/workspace/ConversationsWorkspace.tsx +423 -0
  124. package/src/workspace/index.ts +17 -0
  125. package/src/workspace/labels.test.ts +17 -0
  126. package/src/workspace/labels.ts +85 -0
  127. package/src/workspace/useConversationsInbox.ts +332 -0
  128. package/dist/types-B5C1DLu1.d.ts +0 -365
  129. package/src/preview/AudioRecorderButton.tsx +0 -117
@@ -0,0 +1,571 @@
1
+ /**
2
+ * Tela completa de "Mensagens": mensagens do bot, tópicos, templates do WhatsApp e transcrição.
3
+ *
4
+ * Os formulários daqui já eram do pacote, mas cada produto remontava as abas, o estado e o
5
+ * salvamento por conta — e foi exatamente aí que divergiram (o financiamento tinha tópicos e
6
+ * criação de template, o QuickCart tinha transcrição, e nenhum dos dois tinha tudo). Esta tela
7
+ * é a composição: recebe funções assíncronas e devolve a tela inteira.
8
+ *
9
+ * Tudo além das mensagens de boas-vindas/despedida é **opcional por capacidade**: sem
10
+ * `getTopics`, a seção de tópicos não existe; sem `listTemplates`, a aba de templates não
11
+ * aparece; sem `createTemplate`, o submenu de criação some. Nada de aba que abre em branco.
12
+ */
13
+
14
+ import { useCallback, useEffect, useState, type FormEvent, type ReactNode } from 'react'
15
+ import { WelcomeFarewellForm, type WelcomeFarewellFormLabels } from './WelcomeFarewellForm'
16
+ import { TopicsForm, type TopicItem, type TopicsFormLabels } from './TopicsForm'
17
+ import {
18
+ WhatsAppTemplatesSettings,
19
+ type WhatsAppTemplatesSettingsLabels,
20
+ } from './WhatsAppTemplatesSettings'
21
+ import {
22
+ WhatsAppTemplateSettingsForm,
23
+ type WhatsAppTemplateSummary,
24
+ type WhatsAppTemplateVariableSuggestion,
25
+ type WhatsAppTemplateSettingsFormLabels,
26
+ } from './WhatsAppTemplateSettingsForm'
27
+ import type {
28
+ WhatsAppCreateTemplateFormLabels,
29
+ WhatsAppCreateTemplateResult,
30
+ WhatsAppCreateTemplateState,
31
+ } from './WhatsAppCreateTemplateForm'
32
+ import {
33
+ TranscriptionSettingsForm,
34
+ type TranscriptionSettingsFormLabels,
35
+ } from './TranscriptionSettingsForm'
36
+ import type { TranscriptionMode } from '../types'
37
+ import { TooltipLayer } from '../Tooltip'
38
+
39
+ export interface BotMessages {
40
+ welcomeMessage: string
41
+ farewellMessage: string
42
+ }
43
+
44
+ export interface TemplateSettings {
45
+ templateName: string
46
+ templateLanguage: string
47
+ variables: string[]
48
+ }
49
+
50
+ /**
51
+ * Papel adicional de template (ex.: despedida) além do principal (`getTemplateSettings`/
52
+ * `saveTemplateSettings`). Cada papel ganha seu próprio formulário empilhado na mesma aba, com
53
+ * carregamento e salvamento independentes — é o que permite hosts com mais de um papel (o
54
+ * financiamento tem boas-vindas + despedida) convergirem para este componente em vez de remontar
55
+ * a tela à parte.
56
+ */
57
+ export interface MessagesWorkspaceTemplateRole {
58
+ readonly key: string
59
+ readonly labels: { sectionTitle: string; sectionDescription: string }
60
+ getSettings(): Promise<TemplateSettings>
61
+ saveSettings(settings: TemplateSettings): Promise<void>
62
+ }
63
+
64
+ export interface TranscriptionSettings {
65
+ enabled: boolean
66
+ mode: TranscriptionMode
67
+ available: boolean
68
+ }
69
+
70
+ export interface MessagesWorkspaceApi {
71
+ getMessages(): Promise<BotMessages>
72
+ saveMessages(messages: BotMessages): Promise<void>
73
+ /** Sem este par, a seção de tópicos não é desenhada. */
74
+ getTopics?(): Promise<TopicItem[]>
75
+ saveTopics?(topics: TopicItem[]): Promise<void>
76
+ /** Sem este par, a aba de templates não é desenhada. */
77
+ getTemplateSettings?(): Promise<TemplateSettings>
78
+ saveTemplateSettings?(settings: TemplateSettings): Promise<void>
79
+ /** Papéis adicionais de template (ex.: despedida) além do principal acima. */
80
+ templateRoles?: MessagesWorkspaceTemplateRole[]
81
+ /**
82
+ * Lista de templates aprovados na Meta. Ausente, a aba ainda existe (dá para salvar o nome
83
+ * escolhido), mas nasce sem opções e sem botão de recarregar.
84
+ */
85
+ listTemplates?(): Promise<WhatsAppTemplateSummary[]>
86
+ /** Sem isto, o submenu "Criar template" some. */
87
+ createTemplate?(input: WhatsAppCreateTemplateState): Promise<WhatsAppCreateTemplateResult>
88
+ /** Sem este par, a aba de transcrição não é desenhada. */
89
+ getTranscription?(): Promise<TranscriptionSettings>
90
+ saveTranscription?(settings: Omit<TranscriptionSettings, 'available'>): Promise<void>
91
+ }
92
+
93
+ export interface MessagesWorkspaceLabels {
94
+ title: string
95
+ subtitle: string
96
+ loading: string
97
+ loadError: string
98
+ tabBot: string
99
+ tabTemplates: string
100
+ tabTranscription: string
101
+ welcomeFarewell: Partial<WelcomeFarewellFormLabels>
102
+ topics: Partial<TopicsFormLabels>
103
+ templates: Partial<WhatsAppTemplatesSettingsLabels>
104
+ templateSettings: Partial<WhatsAppTemplateSettingsFormLabels>
105
+ createTemplate: Partial<WhatsAppCreateTemplateFormLabels>
106
+ transcription: Partial<TranscriptionSettingsFormLabels>
107
+ }
108
+
109
+ const DEFAULT_LABELS: MessagesWorkspaceLabels = {
110
+ title: 'Mensagens',
111
+ subtitle: 'Mensagens do bot e templates do WhatsApp.',
112
+ loading: 'Carregando configurações…',
113
+ loadError: 'Não foi possível carregar as configurações de mensagens.',
114
+ tabBot: 'Mensagens do Bot',
115
+ tabTemplates: 'Templates WhatsApp',
116
+ tabTranscription: 'Transcrição de áudio',
117
+ welcomeFarewell: {},
118
+ topics: {},
119
+ templates: {},
120
+ templateSettings: {},
121
+ createTemplate: {},
122
+ transcription: {},
123
+ }
124
+
125
+ const MESSAGES_TAB = {
126
+ BOT: 'bot',
127
+ TEMPLATES: 'templates',
128
+ TRANSCRIPTION: 'transcription',
129
+ } as const
130
+ type MessagesTab = (typeof MESSAGES_TAB)[keyof typeof MESSAGES_TAB]
131
+
132
+ const EMPTY_MESSAGES: BotMessages = { welcomeMessage: '', farewellMessage: '' }
133
+ const EMPTY_TEMPLATE_SETTINGS: TemplateSettings = {
134
+ templateName: '',
135
+ templateLanguage: 'pt_BR',
136
+ variables: [],
137
+ }
138
+ const EMPTY_CREATE_TEMPLATE: WhatsAppCreateTemplateState = {
139
+ name: '',
140
+ category: 'UTILITY',
141
+ language: 'pt_BR',
142
+ headerType: 'NONE',
143
+ headerText: '',
144
+ bodyText: '',
145
+ footerText: '',
146
+ }
147
+
148
+ /**
149
+ * Padrão da instalação quando ninguém decidiu ainda.
150
+ *
151
+ * O servidor guarda `null` de propósito (herda o ambiente), mas o interruptor é booleano —
152
+ * `false` é a leitura honesta de "ninguém ligou aqui"; marcado sugeriria escolha que não houve.
153
+ */
154
+ const UNDECIDED_TRANSCRIPTION: TranscriptionSettings = {
155
+ enabled: false,
156
+ mode: 'onDemand',
157
+ available: false,
158
+ }
159
+
160
+ const SAVE_FEEDBACK_MS = 3000
161
+
162
+ export interface MessagesWorkspaceProps {
163
+ readonly api: MessagesWorkspaceApi
164
+ readonly labels?: Partial<MessagesWorkspaceLabels>
165
+ readonly welcomePlaceholders?: readonly string[]
166
+ readonly farewellPlaceholders?: readonly string[]
167
+ readonly availableVariables?: WhatsAppTemplateVariableSuggestion[]
168
+ /** Aviso do produto acima da aba de templates (ex.: rota da Graph API ainda não implementada). */
169
+ readonly renderTemplatesNotice?: () => ReactNode
170
+ /** Repassados ao formulário de criação — mesma prévia que `WhatsAppCreateTemplateForm` já suporta. */
171
+ readonly createTemplatePreviewCompanyName?: string
172
+ readonly createTemplateVariableExamples?: readonly string[]
173
+ readonly className?: string
174
+ }
175
+
176
+ export function MessagesWorkspace({
177
+ api,
178
+ labels: labelsOverride,
179
+ welcomePlaceholders,
180
+ farewellPlaceholders,
181
+ availableVariables,
182
+ renderTemplatesNotice,
183
+ createTemplatePreviewCompanyName,
184
+ createTemplateVariableExamples,
185
+ className,
186
+ }: MessagesWorkspaceProps) {
187
+ const labels = { ...DEFAULT_LABELS, ...labelsOverride }
188
+ const templateRoles = api.templateRoles ?? []
189
+
190
+ const hasTopics = Boolean(api.getTopics && api.saveTopics)
191
+ const hasTemplates = Boolean(api.getTemplateSettings && api.saveTemplateSettings)
192
+ const hasTranscription = Boolean(api.getTranscription && api.saveTranscription)
193
+
194
+ const [tab, setTab] = useState<MessagesTab>(MESSAGES_TAB.BOT)
195
+ const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>('loading')
196
+
197
+ const [messages, setMessages] = useState<BotMessages>(EMPTY_MESSAGES)
198
+ const [savingMessages, setSavingMessages] = useState(false)
199
+ const [messagesSaved, setMessagesSaved] = useState(false)
200
+
201
+ const [topics, setTopics] = useState<TopicItem[]>([])
202
+ const [savingTopics, setSavingTopics] = useState(false)
203
+ const [topicsSaved, setTopicsSaved] = useState(false)
204
+
205
+ const [templateSettings, setTemplateSettings] = useState<TemplateSettings>(EMPTY_TEMPLATE_SETTINGS)
206
+ const [templates, setTemplates] = useState<WhatsAppTemplateSummary[]>([])
207
+ const [loadingTemplates, setLoadingTemplates] = useState(false)
208
+ const [templatesError, setTemplatesError] = useState(false)
209
+ const [savingTemplate, setSavingTemplate] = useState(false)
210
+ const [templateSaved, setTemplateSaved] = useState(false)
211
+
212
+ const [roleSettings, setRoleSettings] = useState<Record<string, TemplateSettings>>({})
213
+ const [savingRole, setSavingRole] = useState<Record<string, boolean>>({})
214
+ const [roleSaved, setRoleSaved] = useState<Record<string, boolean>>({})
215
+
216
+ const [createTemplate, setCreateTemplate] = useState<WhatsAppCreateTemplateState>(EMPTY_CREATE_TEMPLATE)
217
+ const [creatingTemplate, setCreatingTemplate] = useState(false)
218
+ const [createResult, setCreateResult] = useState<WhatsAppCreateTemplateResult | null>(null)
219
+
220
+ const [transcription, setTranscription] = useState<TranscriptionSettings>(UNDECIDED_TRANSCRIPTION)
221
+ const [savingTranscription, setSavingTranscription] = useState(false)
222
+ const [transcriptionSaved, setTranscriptionSaved] = useState(false)
223
+
224
+ const reloadTemplates = useCallback(async () => {
225
+ if (!api.listTemplates) return
226
+ setLoadingTemplates(true)
227
+ setTemplatesError(false)
228
+ try {
229
+ setTemplates(await api.listTemplates())
230
+ } catch {
231
+ setTemplatesError(true)
232
+ } finally {
233
+ setLoadingTemplates(false)
234
+ }
235
+ }, [api])
236
+
237
+ useEffect(() => {
238
+ let active = true
239
+ async function load(): Promise<void> {
240
+ try {
241
+ const roles = api.templateRoles ?? []
242
+ const [loadedMessages, loadedTopics, loadedTemplateSettings, loadedTranscription, loadedRoleSettings] =
243
+ await Promise.all([
244
+ api.getMessages(),
245
+ api.getTopics?.(),
246
+ api.getTemplateSettings?.(),
247
+ api.getTranscription?.(),
248
+ Promise.all(roles.map((role) => role.getSettings())),
249
+ ])
250
+ if (!active) return
251
+ setMessages(loadedMessages)
252
+ if (loadedTopics) setTopics(loadedTopics)
253
+ if (loadedTemplateSettings) setTemplateSettings(loadedTemplateSettings)
254
+ if (loadedTranscription) setTranscription(loadedTranscription)
255
+ setRoleSettings(Object.fromEntries(roles.map((role, index) => [role.key, loadedRoleSettings[index]])))
256
+ setLoadState('ready')
257
+ } catch {
258
+ if (active) setLoadState('error')
259
+ }
260
+ }
261
+ void load()
262
+ return () => {
263
+ active = false
264
+ }
265
+ }, [api])
266
+
267
+ // A lista de templates é buscada à parte porque falha sozinha: a Graph API cai, ou a rota nem
268
+ // existe no produto, e nada disso pode impedir a tela de abrir para salvar o resto.
269
+ useEffect(() => {
270
+ void reloadTemplates()
271
+ }, [reloadTemplates])
272
+
273
+ function flash(setter: (value: boolean) => void): void {
274
+ setter(true)
275
+ setTimeout(() => setter(false), SAVE_FEEDBACK_MS)
276
+ }
277
+
278
+ async function handleSaveMessages(event: FormEvent): Promise<void> {
279
+ event.preventDefault()
280
+ setSavingMessages(true)
281
+ try {
282
+ await api.saveMessages(messages)
283
+ flash(setMessagesSaved)
284
+ } finally {
285
+ setSavingMessages(false)
286
+ }
287
+ }
288
+
289
+ async function handleSaveTopics(event: FormEvent): Promise<void> {
290
+ event.preventDefault()
291
+ if (!api.saveTopics) return
292
+ setSavingTopics(true)
293
+ try {
294
+ await api.saveTopics(topics)
295
+ flash(setTopicsSaved)
296
+ } finally {
297
+ setSavingTopics(false)
298
+ }
299
+ }
300
+
301
+ async function handleSaveTemplate(event: FormEvent): Promise<void> {
302
+ event.preventDefault()
303
+ if (!api.saveTemplateSettings) return
304
+ setSavingTemplate(true)
305
+ try {
306
+ await api.saveTemplateSettings(templateSettings)
307
+ flash(setTemplateSaved)
308
+ } finally {
309
+ setSavingTemplate(false)
310
+ }
311
+ }
312
+
313
+ function handleSaveRole(role: MessagesWorkspaceTemplateRole): (event: FormEvent) => Promise<void> {
314
+ return async (event: FormEvent) => {
315
+ event.preventDefault()
316
+ const settings = roleSettings[role.key]
317
+ if (!settings) return
318
+ setSavingRole((previous) => ({ ...previous, [role.key]: true }))
319
+ try {
320
+ await role.saveSettings(settings)
321
+ setRoleSaved((previous) => ({ ...previous, [role.key]: true }))
322
+ setTimeout(() => setRoleSaved((previous) => ({ ...previous, [role.key]: false })), SAVE_FEEDBACK_MS)
323
+ } finally {
324
+ setSavingRole((previous) => ({ ...previous, [role.key]: false }))
325
+ }
326
+ }
327
+ }
328
+
329
+ function handleSelectRoleTemplate(
330
+ role: MessagesWorkspaceTemplateRole,
331
+ name: string,
332
+ template: WhatsAppTemplateSummary | undefined,
333
+ ): void {
334
+ setRoleSettings((previous) => {
335
+ const current = previous[role.key] ?? EMPTY_TEMPLATE_SETTINGS
336
+ if (!template) return { ...previous, [role.key]: { ...current, templateName: name } }
337
+ const shouldSeedVariables = template.variableCount > 0 && current.variables.length === 0
338
+ return {
339
+ ...previous,
340
+ [role.key]: {
341
+ templateName: name,
342
+ templateLanguage: template.language,
343
+ variables: shouldSeedVariables ? Array.from({ length: template.variableCount }, () => '') : current.variables,
344
+ },
345
+ }
346
+ })
347
+ }
348
+
349
+ async function handleCreateTemplate(event: FormEvent): Promise<void> {
350
+ event.preventDefault()
351
+ if (!api.createTemplate) return
352
+ setCreatingTemplate(true)
353
+ setCreateResult(null)
354
+ try {
355
+ const result = await api.createTemplate(createTemplate)
356
+ setCreateResult(result)
357
+ if (result.ok) {
358
+ setCreateTemplate(EMPTY_CREATE_TEMPLATE)
359
+ void reloadTemplates()
360
+ }
361
+ } catch (error) {
362
+ setCreateResult({ ok: false, message: error instanceof Error ? error.message : '' })
363
+ } finally {
364
+ setCreatingTemplate(false)
365
+ }
366
+ }
367
+
368
+ async function handleSaveTranscription(event: FormEvent): Promise<void> {
369
+ event.preventDefault()
370
+ if (!api.saveTranscription) return
371
+ setSavingTranscription(true)
372
+ try {
373
+ await api.saveTranscription({ enabled: transcription.enabled, mode: transcription.mode })
374
+ flash(setTranscriptionSaved)
375
+ } finally {
376
+ setSavingTranscription(false)
377
+ }
378
+ }
379
+
380
+ function handleSelectTemplate(name: string, template: WhatsAppTemplateSummary | undefined): void {
381
+ setTemplateSettings((previous) => {
382
+ if (!template) return { ...previous, templateName: name }
383
+ // Escolher um template com variáveis e deixar a lista vazia é o erro clássico: o envio
384
+ // vai à Meta sem os parâmetros e volta rejeitado. Pré-abrir os campos evita isso — mas só
385
+ // quando ninguém preencheu nada ainda, para não apagar mapeamento existente.
386
+ const shouldSeedVariables = template.variableCount > 0 && previous.variables.length === 0
387
+ return {
388
+ templateName: name,
389
+ templateLanguage: template.language,
390
+ variables: shouldSeedVariables
391
+ ? Array.from({ length: template.variableCount }, () => '')
392
+ : previous.variables,
393
+ }
394
+ })
395
+ }
396
+
397
+ if (loadState === 'loading') {
398
+ return <p className="p-6 text-sm text-gray-500 dark:text-gray-400">{labels.loading}</p>
399
+ }
400
+ if (loadState === 'error') {
401
+ return (
402
+ <p role="alert" className="p-6 text-sm text-red-600 dark:text-red-400">
403
+ {labels.loadError}
404
+ </p>
405
+ )
406
+ }
407
+
408
+ const tabs: { id: MessagesTab; label: string }[] = [
409
+ { id: MESSAGES_TAB.BOT, label: labels.tabBot },
410
+ ...(hasTemplates ? [{ id: MESSAGES_TAB.TEMPLATES, label: labels.tabTemplates }] : []),
411
+ ...(hasTranscription ? [{ id: MESSAGES_TAB.TRANSCRIPTION, label: labels.tabTranscription }] : []),
412
+ ]
413
+
414
+ return (
415
+ <div className={`space-y-6 p-4 lg:p-0 ${className ?? ''}`}>
416
+ <TooltipLayer />
417
+ <header>
418
+ <h1 className="text-xl font-semibold text-gray-900 dark:text-gray-100">{labels.title}</h1>
419
+ <p className="text-sm text-gray-500 dark:text-gray-400">{labels.subtitle}</p>
420
+ </header>
421
+
422
+ {/* Uma aba só não é navegação: vira ruído com aparência de escolha. */}
423
+ {tabs.length > 1 ? (
424
+ <nav className="flex gap-1 border-b border-gray-200 dark:border-gray-700" role="tablist">
425
+ {tabs.map((item) => (
426
+ <button
427
+ data-cv-tooltip={item.label} aria-label={item.label}
428
+ key={item.id}
429
+ type="button"
430
+ role="tab"
431
+ aria-selected={tab === item.id}
432
+ onClick={() => setTab(item.id)}
433
+ className={`-mb-px border-b-2 px-4 py-2.5 text-sm font-medium transition-colors ${
434
+ tab === item.id
435
+ ? 'border-blue-600 text-blue-700 dark:border-blue-400 dark:text-blue-400'
436
+ : 'border-transparent text-gray-500 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200'
437
+ }`}
438
+ >
439
+ {item.label}
440
+ </button>
441
+ ))}
442
+ </nav>
443
+ ) : null}
444
+
445
+ {tab === MESSAGES_TAB.BOT ? (
446
+ <div className="space-y-8">
447
+ <WelcomeFarewellForm
448
+ welcomeMessage={messages.welcomeMessage}
449
+ onWelcomeMessageChange={(welcomeMessage) =>
450
+ setMessages((previous) => ({ ...previous, welcomeMessage }))
451
+ }
452
+ farewellMessage={messages.farewellMessage}
453
+ onFarewellMessageChange={(farewellMessage) =>
454
+ setMessages((previous) => ({ ...previous, farewellMessage }))
455
+ }
456
+ onSave={handleSaveMessages}
457
+ saving={savingMessages}
458
+ saveSuccess={messagesSaved}
459
+ labels={labels.welcomeFarewell}
460
+ {...(welcomePlaceholders ? { welcomePlaceholders } : {})}
461
+ {...(farewellPlaceholders ? { farewellPlaceholders } : {})}
462
+ />
463
+
464
+ {hasTopics ? (
465
+ <TopicsForm
466
+ topics={topics}
467
+ onToggle={(key) =>
468
+ setTopics((previous) =>
469
+ previous.map((topic) => (topic.key === key ? { ...topic, enabled: !topic.enabled } : topic)),
470
+ )
471
+ }
472
+ onMessageChange={(key, message) =>
473
+ setTopics((previous) =>
474
+ previous.map((topic) => (topic.key === key ? { ...topic, message } : topic)),
475
+ )
476
+ }
477
+ onSave={handleSaveTopics}
478
+ saving={savingTopics}
479
+ saveSuccess={topicsSaved}
480
+ labels={labels.topics}
481
+ />
482
+ ) : null}
483
+ </div>
484
+ ) : null}
485
+
486
+ {tab === MESSAGES_TAB.TEMPLATES ? (
487
+ <div className="space-y-4">
488
+ {renderTemplatesNotice?.()}
489
+ <WhatsAppTemplatesSettings
490
+ templates={templates}
491
+ loadingTemplates={loadingTemplates}
492
+ templatesError={templatesError}
493
+ selectedTemplateName={templateSettings.templateName}
494
+ onSelectTemplate={handleSelectTemplate}
495
+ variables={templateSettings.variables}
496
+ onVariablesChange={(variables) => setTemplateSettings((previous) => ({ ...previous, variables }))}
497
+ onSave={handleSaveTemplate}
498
+ saving={savingTemplate}
499
+ saveSuccess={templateSaved}
500
+ labels={labels.templates}
501
+ settingsLabels={labels.templateSettings}
502
+ {...(api.listTemplates ? { onRefreshTemplates: () => void reloadTemplates() } : {})}
503
+ {...(availableVariables ? { availableVariables } : {})}
504
+ {...(api.createTemplate
505
+ ? {
506
+ create: {
507
+ value: createTemplate,
508
+ onChange: setCreateTemplate,
509
+ onSubmit: handleCreateTemplate,
510
+ submitting: creatingTemplate,
511
+ result: createResult,
512
+ labels: labels.createTemplate,
513
+ ...(createTemplatePreviewCompanyName ? { previewCompanyName: createTemplatePreviewCompanyName } : {}),
514
+ ...(createTemplateVariableExamples ? { variableExamples: createTemplateVariableExamples } : {}),
515
+ },
516
+ }
517
+ : {})}
518
+ {...(templateRoles.length > 0
519
+ ? {
520
+ extraRoleForms: (
521
+ <>
522
+ {templateRoles.map((role) => {
523
+ const settings = roleSettings[role.key] ?? EMPTY_TEMPLATE_SETTINGS
524
+ return (
525
+ <WhatsAppTemplateSettingsForm
526
+ key={role.key}
527
+ templates={templates}
528
+ loadingTemplates={loadingTemplates}
529
+ templatesError={templatesError}
530
+ selectedTemplateName={settings.templateName}
531
+ onSelectTemplate={(name, template) => handleSelectRoleTemplate(role, name, template)}
532
+ variables={settings.variables}
533
+ onVariablesChange={(variables) =>
534
+ setRoleSettings((previous) => ({
535
+ ...previous,
536
+ [role.key]: { ...(previous[role.key] ?? EMPTY_TEMPLATE_SETTINGS), variables },
537
+ }))
538
+ }
539
+ onSave={handleSaveRole(role)}
540
+ saving={Boolean(savingRole[role.key])}
541
+ saveSuccess={Boolean(roleSaved[role.key])}
542
+ labels={{ ...labels.templateSettings, ...role.labels }}
543
+ {...(api.listTemplates ? { onRefreshTemplates: () => void reloadTemplates() } : {})}
544
+ {...(availableVariables ? { availableVariables } : {})}
545
+ />
546
+ )
547
+ })}
548
+ </>
549
+ ),
550
+ }
551
+ : {})}
552
+ />
553
+ </div>
554
+ ) : null}
555
+
556
+ {tab === MESSAGES_TAB.TRANSCRIPTION ? (
557
+ <TranscriptionSettingsForm
558
+ enabled={transcription.enabled}
559
+ onEnabledChange={(enabled) => setTranscription((previous) => ({ ...previous, enabled }))}
560
+ mode={transcription.mode}
561
+ onModeChange={(mode) => setTranscription((previous) => ({ ...previous, mode }))}
562
+ onSave={handleSaveTranscription}
563
+ isAvailable={transcription.available}
564
+ saving={savingTranscription}
565
+ saveSuccess={transcriptionSaved}
566
+ labels={labels.transcription}
567
+ />
568
+ ) : null}
569
+ </div>
570
+ )
571
+ }
@@ -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"
@@ -0,0 +1,81 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+
4
+ import { TranscriptionSettingsForm } from './TranscriptionSettingsForm'
5
+ import type { TranscriptionSettingsFormProps } from './TranscriptionSettingsForm'
6
+
7
+ /**
8
+ * Extrai uma tag pelo atributo, em vez de casar regex com a ordem dos atributos: o React SSR emite
9
+ * `checked=""` antes de `value=""`, então `/value="auto"[^>]*checked/` falha num componente correto.
10
+ */
11
+ function findTag(markup: string, tagPattern: RegExp, contains: string): string | undefined {
12
+ return (markup.match(tagPattern) ?? []).find((tag) => tag.includes(contains))
13
+ }
14
+
15
+ function render(overrides: Partial<TranscriptionSettingsFormProps> = {}): string {
16
+ return renderToStaticMarkup(
17
+ <TranscriptionSettingsForm
18
+ enabled={false}
19
+ onEnabledChange={() => {}}
20
+ mode="onDemand"
21
+ onModeChange={() => {}}
22
+ onSave={() => {}}
23
+ {...overrides}
24
+ />,
25
+ )
26
+ }
27
+
28
+ describe('TranscriptionSettingsForm', () => {
29
+ it('esconde a escolha de modo enquanto estiver desligado', () => {
30
+ const markup = render({ enabled: false })
31
+
32
+ expect(markup).not.toContain('Quando transcrever')
33
+ })
34
+
35
+ it('mostra as duas opções de modo ao ligar', () => {
36
+ const markup = render({ enabled: true })
37
+
38
+ expect(markup).toContain('Quando transcrever')
39
+ expect(markup).toContain('Quando o atendente pedir')
40
+ expect(markup).toContain('Automaticamente')
41
+ })
42
+
43
+ it('marca o modo atual', () => {
44
+ const markup = render({ enabled: true, mode: 'auto' })
45
+ const radios = /<input type="radio"[^>]*>/g
46
+
47
+ expect(findTag(markup, radios, 'value="auto"')).toContain('checked')
48
+ expect(findTag(markup, radios, 'value="onDemand"')).not.toContain('checked')
49
+ })
50
+
51
+ /**
52
+ * "O ambiente consegue" é diferente de "esta empresa quer". Sem o aviso, o lojista ligaria o
53
+ * interruptor num ambiente sem engine, nada apareceria, e nada explicaria por quê.
54
+ */
55
+ it('avisa e bloqueia quando a capacidade não existe no servidor', () => {
56
+ const markup = render({ isAvailable: false })
57
+
58
+ expect(markup).toContain('não está disponível neste ambiente')
59
+ expect(findTag(markup, /<input type="checkbox"[^>]*>/g, 'type="checkbox"')).toContain('disabled')
60
+ })
61
+
62
+ it('não avisa nada quando a capacidade existe', () => {
63
+ const markup = render({ isAvailable: true })
64
+
65
+ expect(markup).not.toContain('não está disponível neste ambiente')
66
+ })
67
+
68
+ it('aceita rótulos do host', () => {
69
+ const markup = render({ labels: { sectionTitle: 'Áudio em texto' } })
70
+
71
+ expect(markup).toContain('Áudio em texto')
72
+ expect(markup).not.toContain('Transcrição de áudio')
73
+ })
74
+
75
+ it('desabilita o salvar enquanto salva', () => {
76
+ const markup = render({ saving: true })
77
+
78
+ expect(markup).toContain('Salvando...')
79
+ expect(findTag(markup, /<button[^>]*>/g, 'type="submit"')).toContain('disabled')
80
+ })
81
+ })