@adatechnology/conversations-ui 0.1.0-rc.4 → 0.1.0-rc.41

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 (149) hide show
  1. package/dist/ConversationSimulatorPanel--5fIzXWY.d.ts +804 -0
  2. package/dist/chunk-BJNRLLDO.js +2708 -0
  3. package/dist/chunk-DKPXKQGC.js +110 -0
  4. package/dist/{chunk-OGRRHQQW.js → chunk-WCBDXZ3X.js} +68 -4
  5. package/dist/flows/index.d.ts +422 -5
  6. package/dist/flows/index.js +2389 -678
  7. package/dist/index.d.ts +1171 -42
  8. package/dist/index.js +3755 -843
  9. package/dist/preview/index.d.ts +157 -42
  10. package/dist/preview/index.js +772 -191
  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 +14 -3
  19. package/src/ConversationContextPanel.tsx +218 -44
  20. package/src/ConversationDocumentsPanel.tsx +347 -24
  21. package/src/ConversationHeader.test.tsx +66 -0
  22. package/src/ConversationHeader.tsx +163 -45
  23. package/src/ConversationListItem.tsx +19 -2
  24. package/src/ConversationLocalesProvider.tsx +28 -0
  25. package/src/ConversationRow.tsx +31 -7
  26. package/src/DarkModeToggle.test.tsx +76 -0
  27. package/src/DarkModeToggle.tsx +92 -0
  28. package/src/DocumentsLibrary.tsx +382 -0
  29. package/src/EmojiPicker.tsx +70 -55
  30. package/src/FileIcon.test.ts +83 -0
  31. package/src/FileIcon.tsx +88 -11
  32. package/src/InteractiveMessage.test.tsx +41 -0
  33. package/src/InteractiveMessage.tsx +146 -0
  34. package/src/Lightbox.tsx +18 -3
  35. package/src/MediaRenderer.tsx +92 -16
  36. package/src/MessageBubble.test.tsx +41 -0
  37. package/src/MessageBubble.tsx +75 -6
  38. package/src/MessageComposer.test.tsx +35 -0
  39. package/src/MessageComposer.tsx +155 -19
  40. package/src/RichMessageComposer.test.tsx +113 -0
  41. package/src/RichMessageComposer.tsx +551 -0
  42. package/src/SimpleEmojiPicker.tsx +5 -3
  43. package/src/StatusTicks.tsx +1 -1
  44. package/src/Toast.tsx +4 -0
  45. package/src/Tooltip.test.ts +42 -0
  46. package/src/Tooltip.tsx +167 -0
  47. package/src/Wallpaper.test.tsx +21 -0
  48. package/src/Wallpaper.tsx +67 -7
  49. package/src/WhatsAppMessageEditor.tsx +34 -7
  50. package/src/WindowExpiredNotice.tsx +12 -4
  51. package/src/audioRecorderFormat.test.ts +67 -0
  52. package/src/buildOutput.test.ts +79 -0
  53. package/src/composer.constant.ts +33 -0
  54. package/src/conversationTranscript.test.ts +57 -0
  55. package/src/conversationTranscript.ts +29 -4
  56. package/src/conversationWindow.ts +7 -5
  57. package/src/documentTypeLabel.test.ts +57 -0
  58. package/src/documents/DocumentsWorkspace.tsx +550 -0
  59. package/src/documents/index.ts +8 -0
  60. package/src/documents/labels.ts +92 -0
  61. package/src/emojiCatalog.test.ts +35 -0
  62. package/src/emojiCatalog.ts +189 -0
  63. package/src/flows/FlowConnectionEdge.tsx +104 -0
  64. package/src/flows/FlowGroupHeader.tsx +12 -2
  65. package/src/flows/FlowLegend.tsx +125 -0
  66. package/src/flows/FlowMapCanvas.tsx +15 -12
  67. package/src/flows/FlowMapNode.tsx +4 -1
  68. package/src/flows/FlowNodeCard.tsx +219 -34
  69. package/src/flows/FlowNodePanel.tsx +153 -39
  70. package/src/flows/FlowPalette.tsx +106 -69
  71. package/src/flows/FlowPortalNode.tsx +1 -1
  72. package/src/flows/FlowWhatsAppPreview.tsx +14 -3
  73. package/src/flows/FlowsWorkspace.tsx +1219 -0
  74. package/src/flows/flowCanvasModel.test.ts +456 -0
  75. package/src/flows/flowCanvasModel.ts +378 -0
  76. package/src/flows/flowEditorOps.test.ts +276 -0
  77. package/src/flows/flowEditorOps.ts +202 -0
  78. package/src/flows/flowGraph.ts +78 -53
  79. package/src/flows/index.ts +51 -2
  80. package/src/flows/labels.ts +180 -0
  81. package/src/flows/workspaceContract.test.ts +126 -0
  82. package/src/hooks/useContainerWidth.ts +35 -0
  83. package/src/hooks/useConversationActions.ts +56 -0
  84. package/src/hooks/useConversationDocuments.ts +11 -7
  85. package/src/hooks/useConversationList.ts +15 -9
  86. package/src/hooks/useConversationMessages.ts +2 -2
  87. package/src/hooks/useConversationRealtime.ts +10 -8
  88. package/src/hooks/useScrollToLatestMessage.ts +127 -0
  89. package/src/hooks/useUrlFilterState.ts +107 -0
  90. package/src/icon.constant.ts +12 -0
  91. package/src/index.ts +129 -13
  92. package/src/lib/cn.test.ts +29 -0
  93. package/src/lib/composer-formatting.test.ts +78 -0
  94. package/src/lib/composer-formatting.ts +145 -0
  95. package/src/lib/createMediaUrlResolver.ts +33 -0
  96. package/src/lib/paginated.test.ts +33 -0
  97. package/src/lib/paginated.ts +26 -0
  98. package/src/lib/whatsapp-formatting.test.tsx +37 -0
  99. package/src/lib/whatsapp-formatting.tsx +28 -3
  100. package/src/listing/index.tsx +202 -0
  101. package/src/pagination.constant.ts +10 -0
  102. package/src/preview/ConversationPreview.tsx +225 -17
  103. package/src/preview/ConversationSimulatorClient.ts +143 -0
  104. package/src/preview/ConversationSimulatorPanel.test.tsx +55 -0
  105. package/src/preview/ConversationSimulatorPanel.tsx +131 -0
  106. package/src/preview/MediaTypesPreview.tsx +87 -0
  107. package/src/preview/conversationPreviewFailures.test.ts +64 -0
  108. package/src/preview/createMockConversationsApi.ts +175 -15
  109. package/src/preview/createPreviewBridgeClient.test.ts +92 -0
  110. package/src/preview/createPreviewBridgeClient.ts +124 -0
  111. package/src/preview/createPreviewMediaUploader.ts +82 -0
  112. package/src/preview/createPreviewWebhookClient.test.ts +96 -0
  113. package/src/preview/createPreviewWebhookClient.ts +127 -4
  114. package/src/preview/index.ts +51 -3
  115. package/src/preview/mediaTypeOf.test.ts +15 -0
  116. package/src/preview/mockDocumentsSearch.test.ts +57 -0
  117. package/src/preview/preview.test.ts +5 -3
  118. package/src/preview/previewFileSamples.test.ts +151 -0
  119. package/src/preview/previewFileSamples.ts +74 -0
  120. package/src/preview/previewFixtures.ts +288 -1
  121. package/src/preview/previewMediaSource.test.ts +62 -0
  122. package/src/preview/previewMediaSource.ts +91 -0
  123. package/src/preview/previewMediaUploader.test.ts +61 -0
  124. package/src/providers/ConversationsProvider.tsx +8 -6
  125. package/src/providers/types.ts +185 -10
  126. package/src/quickReply.test.ts +58 -0
  127. package/src/settings/MessagesWorkspace.tsx +571 -0
  128. package/src/settings/TopicsForm.tsx +2 -0
  129. package/src/settings/TranscriptionSettingsForm.test.tsx +81 -0
  130. package/src/settings/TranscriptionSettingsForm.tsx +190 -0
  131. package/src/settings/WelcomeFarewellForm.tsx +1 -0
  132. package/src/settings/WhatsAppCreateTemplateForm.tsx +4 -1
  133. package/src/settings/WhatsAppTemplateSettingsForm.tsx +5 -2
  134. package/src/settings/WhatsAppTemplatesSettings.test.tsx +61 -0
  135. package/src/settings/WhatsAppTemplatesSettings.tsx +22 -2
  136. package/src/styles.css +858 -0
  137. package/src/types.ts +64 -1
  138. package/src/useWaitingNotifications.ts +74 -29
  139. package/src/workspace/BulkTemplateModal.tsx +132 -0
  140. package/src/workspace/ConversationPane.tsx +432 -0
  141. package/src/workspace/ConversationsInboxList.tsx +194 -0
  142. package/src/workspace/ConversationsWorkspace.tsx +423 -0
  143. package/src/workspace/index.ts +17 -0
  144. package/src/workspace/labels.test.ts +17 -0
  145. package/src/workspace/labels.ts +85 -0
  146. package/src/workspace/useConversationsInbox.ts +332 -0
  147. package/dist/chunk-4R6Y43DQ.js +0 -726
  148. package/dist/chunk-NV2RZ5KT.js +0 -56
  149. package/dist/types-C0PtaO7S.d.ts +0 -207
@@ -1,8 +1,8 @@
1
- import { useState } from 'react'
1
+ import { useState, type ReactNode } from 'react'
2
2
  import { Plus, Trash2, Save, X, AlertTriangle, AlertCircle } from 'lucide-react'
3
3
  import { FlowWhatsAppPreview } from './FlowWhatsAppPreview'
4
4
  import { nodeLabel } from './FlowNodeCard'
5
- import { CROSS_FLOW_PREFIX, CONDITION_OPERATORS } from './flowGraph'
5
+ import { CROSS_FLOW_PREFIX, CONDITION_OPERATORS, BUILT_IN_ACTION_KINDS, PASS_THROUGH_ACTION_KINDS } from './flowGraph'
6
6
  import { DEFAULT_FLOW_EDITOR_LABELS, type FlowEditorLabels } from './labels'
7
7
  import type { FlowGraphData, FlowNodeData, GraphIssue } from './flowGraph'
8
8
 
@@ -73,6 +73,14 @@ export interface FlowNodePanelProps {
73
73
  onChange: (updated: FlowNodeData) => void
74
74
  onDelete: (nodeId: string) => void
75
75
  labels?: Partial<FlowEditorLabels>
76
+ /**
77
+ * Seletor de arquivos do nó `send_media`, renderizado no lugar da mensagem direta.
78
+ *
79
+ * Slot, e não uma lista de arquivos por prop, porque a biblioteca é do host: upload, permissão e
80
+ * URL assinada são dele, e o painel não tem como buscar nada. Ausente, o nó continua editável —
81
+ * só não dá para anexar por aqui.
82
+ */
83
+ renderMediaPicker?: (node: FlowNodeData, graph: FlowGraphData) => ReactNode
76
84
  }
77
85
 
78
86
  // Paridade com financiamento-imobiliario-bot/apps/web/src/components/flows/FlowNodePanel.tsx —
@@ -86,18 +94,27 @@ export function FlowNodePanel({
86
94
  onChange,
87
95
  onDelete,
88
96
  labels: labelsOverride,
97
+ renderMediaPicker,
89
98
  }: FlowNodePanelProps) {
90
99
  const labels = { ...DEFAULT_FLOW_EDITOR_LABELS, ...labelsOverride }
91
100
  const [draft, setDraft] = useState<FlowNodeData>(node)
92
101
  const otherNodeIds = Object.keys(graph.nodes).filter((id) => id !== node.id)
93
102
  const isFixedLogic = draft.type === 'entrada_choice'
94
103
  const isAction = draft.type === 'action'
104
+ const isSendMedia = isAction && draft.actionKind === BUILT_IN_ACTION_KINDS.SEND_MEDIA
105
+ const isPassThroughAction = isAction && Boolean(draft.actionKind && PASS_THROUGH_ACTION_KINDS.includes(draft.actionKind))
95
106
  const isCondition = draft.type === 'condition'
96
107
  const isStart = graph.startNodeId === node.id
97
108
  const nodeIssues = issues.filter((i) => i.nodeId === node.id)
98
109
  // Chaves já usadas por perguntas deste fluxo — sugestão pro campo de variável da condição,
99
110
  // sem travar em texto livre (a variável pode ter vindo de outro fluxo ou de um cálculo derivado).
100
- const knownContextKeys = [...new Set(Object.values(graph.nodes).map((n) => n.contextKey).filter((key): key is string => !!key))]
111
+ const knownContextKeys = [
112
+ ...new Set(
113
+ Object.values(graph.nodes)
114
+ .map((n) => n.contextKey)
115
+ .filter((key): key is string => !!key),
116
+ ),
117
+ ]
101
118
  const conditionAnswerIds = isCondition ? ['true', 'false'] : (draft.options ?? []).map(([id]) => id)
102
119
 
103
120
  function updateNextString(value: string) {
@@ -106,7 +123,8 @@ export function FlowNodePanel({
106
123
 
107
124
  function updateNextByAnswer(answerId: string, value: string) {
108
125
  setDraft((prev) => {
109
- const current = typeof prev.next === 'object' && prev.next ? prev.next : { byAnswer: {}, default: otherNodeIds[0] ?? '' }
126
+ const current =
127
+ typeof prev.next === 'object' && prev.next ? prev.next : { byAnswer: {}, default: otherNodeIds[0] ?? '' }
110
128
  return { ...prev, next: { ...current, byAnswer: { ...current.byAnswer, [answerId]: value } } }
111
129
  })
112
130
  }
@@ -129,7 +147,10 @@ export function FlowNodePanel({
129
147
  }
130
148
 
131
149
  function addOption() {
132
- setDraft((prev) => ({ ...prev, options: [...(prev.options ?? []), [String((prev.options?.length ?? 0) + 1), 'Nova opção']] }))
150
+ setDraft((prev) => ({
151
+ ...prev,
152
+ options: [...(prev.options ?? []), [String((prev.options?.length ?? 0) + 1), 'Nova opção']],
153
+ }))
133
154
  }
134
155
 
135
156
  function removeOption(index: number) {
@@ -140,12 +161,16 @@ export function FlowNodePanel({
140
161
  return (
141
162
  <>
142
163
  {otherNodeIds.map((id) => (
143
- <option key={id} value={id}>{truncateLabel(nodeLabel(graph.nodes[id], labels))}</option>
164
+ <option key={id} value={id}>
165
+ {truncateLabel(nodeLabel(graph.nodes[id], labels))}
166
+ </option>
144
167
  ))}
145
168
  {otherFlows.length > 0 && (
146
169
  <optgroup label={labels.nodePanel.otherFlowsGroup}>
147
170
  {otherFlows.map((flow) => (
148
- <option key={flow.key} value={`${CROSS_FLOW_PREFIX}${flow.key}`}>{flow.label}</option>
171
+ <option key={flow.key} value={`${CROSS_FLOW_PREFIX}${flow.key}`}>
172
+ {flow.label}
173
+ </option>
149
174
  ))}
150
175
  </optgroup>
151
176
  )}
@@ -157,13 +182,17 @@ export function FlowNodePanel({
157
182
  <div className="fixed inset-y-0 right-0 w-96 bg-white dark:bg-gray-800 border-l border-gray-200 dark:border-gray-700 shadow-xl z-50 flex flex-col">
158
183
  <div className="flex items-center justify-between px-4 py-3 border-b border-gray-100 dark:border-gray-700">
159
184
  <h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">{labels.nodePanel.title}</h3>
160
- <button onClick={onClose} className="text-gray-400 hover:text-gray-600"><X size={18} /></button>
185
+ <button data-cv-tooltip={labels.nodePanel.close} aria-label={labels.nodePanel.close} onClick={onClose} className="text-gray-400 hover:text-gray-600">
186
+ <X size={18} />
187
+ </button>
161
188
  </div>
162
189
 
163
190
  <div className="flex-1 overflow-y-auto p-4 space-y-4">
164
191
  {nodeIssues.length > 0 && (
165
192
  <div className="rounded-lg border border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/40 p-2.5 space-y-1.5">
166
- {nodeIssues.map((issue, i) => <IssueRow key={i} issue={issue} />)}
193
+ {nodeIssues.map((issue, i) => (
194
+ <IssueRow key={i} issue={issue} />
195
+ ))}
167
196
  </div>
168
197
  )}
169
198
 
@@ -183,23 +212,48 @@ export function FlowNodePanel({
183
212
  </p>
184
213
  )}
185
214
 
215
+ <div>
216
+ <label className="text-xs font-medium text-gray-500 dark:text-gray-400">
217
+ {labels.nodePanel.nodeName}
218
+ </label>
219
+ <input
220
+ value={draft.label ?? ''}
221
+ placeholder={labels.nodePanel.nodeNamePlaceholder}
222
+ onChange={(event) => setDraft((prev) => ({ ...prev, label: event.target.value || undefined }))}
223
+ className={`w-full mt-1 ${INPUT_CLASSNAME}`}
224
+ />
225
+ <p className="text-[11px] text-gray-400 dark:text-gray-500 mt-1">{labels.nodePanel.nodeNameHint}</p>
226
+ </div>
227
+
186
228
  {draft.contextKey && (
187
229
  <div>
188
- <label className="text-xs font-medium text-gray-500 dark:text-gray-400">{labels.nodePanel.contextKey}</label>
189
- <input value={draft.contextKey} disabled className="w-full mt-1 rounded-xl border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 px-3 py-2.5 text-sm text-gray-500" />
230
+ <label className="text-xs font-medium text-gray-500 dark:text-gray-400">
231
+ {labels.nodePanel.contextKey}
232
+ </label>
233
+ <input
234
+ value={draft.contextKey}
235
+ disabled
236
+ className="w-full mt-1 rounded-xl border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 px-3 py-2.5 text-sm text-gray-500"
237
+ />
190
238
  </div>
191
239
  )}
192
240
 
193
241
  {!isFixedLogic && !isAction && !isCondition && (
194
242
  <div>
195
- <label className="text-xs font-medium text-gray-500 dark:text-gray-400">{labels.nodePanel.questionType}</label>
243
+ <label className="text-xs font-medium text-gray-500 dark:text-gray-400">
244
+ {labels.nodePanel.questionType}
245
+ </label>
196
246
  <select
197
247
  value={draft.questionType ?? 'text'}
198
- onChange={(e) => setDraft((prev) => ({ ...prev, questionType: e.target.value as FlowNodeData['questionType'] }))}
248
+ onChange={(e) =>
249
+ setDraft((prev) => ({ ...prev, questionType: e.target.value as FlowNodeData['questionType'] }))
250
+ }
199
251
  className={`w-full mt-1 ${SELECT_CLASSNAME}`}
200
252
  >
201
253
  {Object.entries(labels.questionTypeLabels).map(([key, label]) => (
202
- <option key={key} value={key}>{label}</option>
254
+ <option key={key} value={key}>
255
+ {label}
256
+ </option>
203
257
  ))}
204
258
  </select>
205
259
  </div>
@@ -218,7 +272,9 @@ export function FlowNodePanel({
218
272
  {isCondition && (
219
273
  <div className="space-y-3">
220
274
  <div>
221
- <label className="text-xs font-medium text-gray-500 dark:text-gray-400">{labels.nodePanel.conditionVariable}</label>
275
+ <label className="text-xs font-medium text-gray-500 dark:text-gray-400">
276
+ {labels.nodePanel.conditionVariable}
277
+ </label>
222
278
  <input
223
279
  value={draft.conditionContextKey ?? ''}
224
280
  onChange={(e) => setDraft((prev) => ({ ...prev, conditionContextKey: e.target.value }))}
@@ -226,23 +282,36 @@ export function FlowNodePanel({
226
282
  className={`w-full mt-1 ${INPUT_CLASSNAME}`}
227
283
  />
228
284
  <datalist id="condition-context-keys">
229
- {knownContextKeys.map((key) => <option key={key} value={key} />)}
285
+ {knownContextKeys.map((key) => (
286
+ <option key={key} value={key} />
287
+ ))}
230
288
  </datalist>
231
289
  </div>
232
290
  <div>
233
- <label className="text-xs font-medium text-gray-500 dark:text-gray-400">{labels.nodePanel.conditionOperator}</label>
291
+ <label className="text-xs font-medium text-gray-500 dark:text-gray-400">
292
+ {labels.nodePanel.conditionOperator}
293
+ </label>
234
294
  <select
235
295
  value={draft.conditionOperator ?? '>'}
236
- onChange={(e) => setDraft((prev) => ({ ...prev, conditionOperator: e.target.value as FlowNodeData['conditionOperator'] }))}
296
+ onChange={(e) =>
297
+ setDraft((prev) => ({
298
+ ...prev,
299
+ conditionOperator: e.target.value as FlowNodeData['conditionOperator'],
300
+ }))
301
+ }
237
302
  className={`w-full mt-1 ${SELECT_CLASSNAME}`}
238
303
  >
239
304
  {CONDITION_OPERATORS.map((operator) => (
240
- <option key={operator} value={operator}>{labels.conditionOperatorLabels[operator] ?? operator}</option>
305
+ <option key={operator} value={operator}>
306
+ {labels.conditionOperatorLabels[operator] ?? operator}
307
+ </option>
241
308
  ))}
242
309
  </select>
243
310
  </div>
244
311
  <div>
245
- <label className="text-xs font-medium text-gray-500 dark:text-gray-400">{labels.nodePanel.conditionValue}</label>
312
+ <label className="text-xs font-medium text-gray-500 dark:text-gray-400">
313
+ {labels.nodePanel.conditionValue}
314
+ </label>
246
315
  <input
247
316
  value={draft.conditionValue ?? ''}
248
317
  onChange={(e) => setDraft((prev) => ({ ...prev, conditionValue: e.target.value }))}
@@ -262,7 +331,21 @@ export function FlowNodePanel({
262
331
  />
263
332
  )}
264
333
 
265
- {isAction && draft.actionKind !== 'send_product_list' && (
334
+ {/* Os arquivos moram na biblioteca, não no grafo — trocar o material não repassa pelo editor. */}
335
+ {isSendMedia && (
336
+ <div>
337
+ <label className="text-xs font-medium text-gray-500 dark:text-gray-400">{labels.nodePanel.media}</label>
338
+ <div className="mt-1">
339
+ {renderMediaPicker?.(node, graph) ?? (
340
+ <p className="text-xs text-gray-400">{labels.nodePanel.mediaUnavailable}</p>
341
+ )}
342
+ </div>
343
+ </div>
344
+ )}
345
+
346
+ {/* `send_media` fica de fora: o handler do módulo só envia os anexos, então um campo de
347
+ mensagem aqui seria texto que o cliente nunca recebe. A legenda é por arquivo. */}
348
+ {isAction && draft.actionKind !== 'send_product_list' && !isSendMedia && (
266
349
  <WhatsAppTextField
267
350
  label={labels.nodePanel.directMessage}
268
351
  value={draft.directMessage ?? ''}
@@ -276,31 +359,46 @@ export function FlowNodePanel({
276
359
  <div>
277
360
  <div className="flex items-center justify-between mb-1.5">
278
361
  <label className="text-xs font-medium text-gray-500 dark:text-gray-400">{labels.nodePanel.options}</label>
279
- <button onClick={addOption} className="text-xs text-blue-600 hover:underline flex items-center gap-1">
362
+ <button data-cv-tooltip={labels.nodePanel.addOption} aria-label={labels.nodePanel.addOption} onClick={addOption} className="text-xs text-blue-600 hover:underline flex items-center gap-1">
280
363
  <Plus size={12} /> {labels.nodePanel.addOption}
281
364
  </button>
282
365
  </div>
283
366
  <div className="space-y-2">
284
367
  {(draft.options ?? []).map(([id, label], i) => (
285
368
  <div key={i} className="flex items-center gap-2">
286
- <input value={id} onChange={(e) => updateOption(i, 0, e.target.value)} placeholder={labels.nodePanel.optionId}
287
- className={`w-16 ${INPUT_CLASSNAME}`} />
288
- <input value={label} onChange={(e) => updateOption(i, 1, e.target.value)} placeholder={labels.nodePanel.optionLabel}
289
- className={`flex-1 ${INPUT_CLASSNAME}`} />
290
- <button onClick={() => removeOption(i)} className="text-gray-400 hover:text-red-600"><Trash2 size={14} /></button>
369
+ <input
370
+ value={id}
371
+ onChange={(e) => updateOption(i, 0, e.target.value)}
372
+ placeholder={labels.nodePanel.optionId}
373
+ className={`w-16 ${INPUT_CLASSNAME}`}
374
+ />
375
+ <input
376
+ value={label}
377
+ onChange={(e) => updateOption(i, 1, e.target.value)}
378
+ placeholder={labels.nodePanel.optionLabel}
379
+ className={`flex-1 ${INPUT_CLASSNAME}`}
380
+ />
381
+ <button data-cv-tooltip={labels.nodePanel.removeOption} aria-label={labels.nodePanel.removeOption} onClick={() => removeOption(i)} className="text-gray-400 hover:text-red-600">
382
+ <Trash2 size={14} />
383
+ </button>
291
384
  </div>
292
385
  ))}
293
386
  </div>
294
387
  </div>
295
388
  )}
296
389
 
297
- {!isAction && (
390
+ {/* Ação de passagem também escolhe destino: sem isto, o material era enviado e a conversa
391
+ parava ali, sem caminho para o atendimento. Ação terminal continua sem o campo. */}
392
+ {(!isAction || isPassThroughAction) && (
298
393
  <div className="pt-2 border-t border-gray-100 dark:border-gray-700">
299
394
  <label className="text-xs font-medium text-gray-500 dark:text-gray-400">{labels.nodePanel.next}</label>
300
395
  <p className="text-[11px] text-gray-400 dark:text-gray-500 mb-1">{labels.nodePanel.nextHint}</p>
301
396
  {typeof draft.next !== 'object' && !isCondition ? (
302
- <select value={typeof draft.next === 'string' ? draft.next : ''} onChange={(e) => updateNextString(e.target.value)}
303
- className={`w-full mt-1 ${SELECT_CLASSNAME}`}>
397
+ <select
398
+ value={typeof draft.next === 'string' ? draft.next : ''}
399
+ onChange={(e) => updateNextString(e.target.value)}
400
+ className={`w-full mt-1 ${SELECT_CLASSNAME}`}
401
+ >
304
402
  <option value="">—</option>
305
403
  {nextNodeOptions()}
306
404
  </select>
@@ -309,10 +407,17 @@ export function FlowNodePanel({
309
407
  {conditionAnswerIds.map((id) => (
310
408
  <div key={id} className="flex items-center gap-2">
311
409
  <span className="text-xs text-gray-500 w-32 shrink-0">
312
- {isCondition ? (id === 'true' ? labels.nodePanel.conditionTrue : labels.nodePanel.conditionFalse) : labels.nodePanel.nextByAnswer(id)}
410
+ {isCondition
411
+ ? id === 'true'
412
+ ? labels.nodePanel.conditionTrue
413
+ : labels.nodePanel.conditionFalse
414
+ : labels.nodePanel.nextByAnswer(id)}
313
415
  </span>
314
- <select value={draft.next && typeof draft.next === 'object' ? draft.next.byAnswer[id] ?? '' : ''} onChange={(e) => updateNextByAnswer(id, e.target.value)}
315
- className={`flex-1 ${SELECT_CLASSNAME}`}>
416
+ <select
417
+ value={draft.next && typeof draft.next === 'object' ? (draft.next.byAnswer[id] ?? '') : ''}
418
+ onChange={(e) => updateNextByAnswer(id, e.target.value)}
419
+ className={`flex-1 ${SELECT_CLASSNAME}`}
420
+ >
316
421
  <option value="">—</option>
317
422
  {nextNodeOptions()}
318
423
  </select>
@@ -322,8 +427,11 @@ export function FlowNodePanel({
322
427
  <span className="text-xs text-gray-500 w-32 shrink-0">
323
428
  {isCondition ? labels.nodePanel.conditionVariableMissing : labels.nodePanel.nextDefault}
324
429
  </span>
325
- <select value={draft.next && typeof draft.next === 'object' ? draft.next.default : ''} onChange={(e) => updateNextDefault(e.target.value)}
326
- className={`flex-1 ${SELECT_CLASSNAME}`}>
430
+ <select
431
+ value={draft.next && typeof draft.next === 'object' ? draft.next.default : ''}
432
+ onChange={(e) => updateNextDefault(e.target.value)}
433
+ className={`flex-1 ${SELECT_CLASSNAME}`}
434
+ >
327
435
  <option value="">—</option>
328
436
  {nextNodeOptions()}
329
437
  </select>
@@ -335,19 +443,25 @@ export function FlowNodePanel({
335
443
  </div>
336
444
 
337
445
  <div className="p-4 border-t border-gray-100 dark:border-gray-700 flex gap-2">
338
- <button onClick={() => onChange(draft)} className="flex-1 inline-flex items-center justify-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
446
+ <button
447
+ data-cv-tooltip={labels.nodePanel.save} aria-label={labels.nodePanel.save}
448
+ onClick={() => onChange(draft)}
449
+ className="flex-1 inline-flex items-center justify-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
450
+ >
339
451
  <Save size={14} /> {labels.nodePanel.save}
340
452
  </button>
341
453
  {!isStart && (
342
454
  <button
343
- onClick={() => { if (window.confirm(labels.nodePanel.deleteConfirm)) onDelete(node.id) }}
344
- title={labels.nodePanel.delete}
455
+ onClick={() => {
456
+ if (window.confirm(labels.nodePanel.deleteConfirm)) onDelete(node.id)
457
+ }}
458
+ data-cv-tooltip={labels.nodePanel.delete} aria-label={labels.nodePanel.delete}
345
459
  className="px-3 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/30 rounded-lg"
346
460
  >
347
461
  <Trash2 size={14} />
348
462
  </button>
349
463
  )}
350
- <button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 dark:text-gray-300 hover:underline">
464
+ <button data-cv-tooltip={labels.nodePanel.cancel} aria-label={labels.nodePanel.cancel} onClick={onClose} className="px-4 py-2 text-sm text-gray-600 dark:text-gray-300 hover:underline">
351
465
  {labels.nodePanel.cancel}
352
466
  </button>
353
467
  </div>
@@ -28,21 +28,117 @@ const QUESTION_TYPES: FlowQuestionType[] = ['text', 'money', 'date', 'int', 'cpf
28
28
  // Paridade com financiamento-imobiliario-bot/apps/web/src/components/flows/FlowPalette.tsx —
29
29
  // botão "Adicionar" com submenus por categoria (Pergunta/Decisão/Condição/Ação). Sem
30
30
  // dependência de dropdown externa — menu simples com fechamento por clique fora.
31
- export function FlowPalette({ onAdd, labels: labelsOverride, actionOptions }: FlowPaletteProps) {
32
- const labels = { ...DEFAULT_FLOW_EDITOR_LABELS, ...labelsOverride }
31
+ export interface FlowPaletteMenuProps {
32
+ onSelect: (spec: NewNodeSpec) => void
33
+ labels: FlowEditorLabels
34
+ actionOptions?: FlowPaletteActionOption[]
35
+ }
36
+
37
+ /**
38
+ * A lista de tipos de nó, sem o botão que a abre.
39
+ *
40
+ * Separada porque o "+" do card abre exatamente esta lista: duplicar os itens era garantir que um
41
+ * tipo novo aparecesse num caminho e faltasse no outro.
42
+ */
43
+ export function FlowPaletteMenu({ onSelect, labels, actionOptions }: FlowPaletteMenuProps) {
33
44
  const resolvedActionOptions = actionOptions ?? [
34
45
  { actionKind: 'handoff', label: labels.actionKindLabels.handoff ?? 'Encaminhar para atendimento' },
35
46
  ]
47
+ const [submenu, setSubmenu] = useState<'question' | 'action' | null>(null)
48
+
49
+ function select(spec: NewNodeSpec) {
50
+ onSelect(spec)
51
+ setSubmenu(null)
52
+ }
53
+
54
+ return (
55
+ <>
56
+ <div className="relative">
57
+ <button
58
+ data-cv-tooltip={labels.palette.question} aria-label={labels.palette.question}
59
+ onMouseEnter={() => setSubmenu('question')}
60
+ onClick={() => setSubmenu(submenu === 'question' ? null : 'question')}
61
+ className="w-full flex items-center justify-between gap-2 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
62
+ >
63
+ <span className="flex items-center gap-2">
64
+ <MessageCircleQuestion size={15} className="text-blue-500" /> {labels.palette.question}
65
+ </span>
66
+ <ChevronRight size={13} className="text-gray-400" />
67
+ </button>
68
+ {submenu === 'question' && (
69
+ <div className="absolute left-full top-0 ml-1 w-56 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-lg py-1">
70
+ {QUESTION_TYPES.map((qt) => (
71
+ <button
72
+ data-cv-tooltip={labels.questionTypeLabels[qt]} aria-label={labels.questionTypeLabels[qt]}
73
+ key={qt}
74
+ onClick={() => select({ kind: 'question', questionType: qt })}
75
+ className="w-full text-left px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
76
+ >
77
+ {labels.questionTypeLabels[qt]}
78
+ </button>
79
+ ))}
80
+ </div>
81
+ )}
82
+ </div>
83
+
84
+ <button
85
+ data-cv-tooltip={labels.palette.decision} aria-label={labels.palette.decision}
86
+ onMouseEnter={() => setSubmenu(null)}
87
+ onClick={() => select({ kind: 'decision' })}
88
+ className="w-full flex items-center gap-2 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
89
+ >
90
+ <GitBranch size={15} className="text-purple-500" /> {labels.palette.decision}
91
+ </button>
92
+
93
+ <button
94
+ onMouseEnter={() => setSubmenu(null)}
95
+ onClick={() => select({ kind: 'condition' })}
96
+ className="w-full flex items-center gap-2 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
97
+ data-cv-tooltip={labels.palette.conditionHint} aria-label={labels.palette.conditionHint}
98
+ >
99
+ <Diamond size={15} className="text-cyan-500" /> {labels.palette.condition}
100
+ </button>
101
+
102
+ <div className="relative">
103
+ <button
104
+ data-cv-tooltip={labels.palette.action} aria-label={labels.palette.action}
105
+ onMouseEnter={() => setSubmenu('action')}
106
+ onClick={() => setSubmenu(submenu === 'action' ? null : 'action')}
107
+ className="w-full flex items-center justify-between gap-2 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
108
+ >
109
+ <span className="flex items-center gap-2">
110
+ <Zap size={15} className="text-orange-500" /> {labels.palette.action}
111
+ </span>
112
+ <ChevronRight size={13} className="text-gray-400" />
113
+ </button>
114
+ {submenu === 'action' && (
115
+ <div className="absolute left-full top-0 ml-1 w-64 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-lg py-1">
116
+ {resolvedActionOptions.map((option) => (
117
+ <button
118
+ data-cv-tooltip={option.label} aria-label={option.label}
119
+ key={option.actionKind}
120
+ onClick={() => select({ kind: 'action', actionKind: option.actionKind })}
121
+ className="w-full text-left px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
122
+ >
123
+ {option.label}
124
+ </button>
125
+ ))}
126
+ </div>
127
+ )}
128
+ </div>
129
+ </>
130
+ )
131
+ }
36
132
 
133
+ export function FlowPalette({ onAdd, labels: labelsOverride, actionOptions }: FlowPaletteProps) {
134
+ const labels = { ...DEFAULT_FLOW_EDITOR_LABELS, ...labelsOverride }
37
135
  const [open, setOpen] = useState(false)
38
- const [submenu, setSubmenu] = useState<'question' | 'action' | null>(null)
39
136
  const containerRef = useRef<HTMLDivElement>(null)
40
137
 
41
138
  useEffect(() => {
42
139
  function handleClickOutside(event: MouseEvent) {
43
140
  if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
44
141
  setOpen(false)
45
- setSubmenu(null)
46
142
  }
47
143
  }
48
144
  document.addEventListener('mousedown', handleClickOutside)
@@ -52,12 +148,12 @@ export function FlowPalette({ onAdd, labels: labelsOverride, actionOptions }: Fl
52
148
  function select(spec: NewNodeSpec) {
53
149
  onAdd(spec)
54
150
  setOpen(false)
55
- setSubmenu(null)
56
151
  }
57
152
 
58
153
  return (
59
154
  <div ref={containerRef} className="relative">
60
155
  <button
156
+ data-cv-tooltip={labels.palette.title} aria-label={labels.palette.title}
61
157
  onClick={() => setOpen((v) => !v)}
62
158
  className="inline-flex items-center gap-1.5 rounded-lg bg-blue-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-blue-700"
63
159
  >
@@ -66,70 +162,11 @@ export function FlowPalette({ onAdd, labels: labelsOverride, actionOptions }: Fl
66
162
 
67
163
  {open && (
68
164
  <div className="absolute left-0 top-full mt-1.5 w-64 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-lg z-50 py-1">
69
- <div className="relative">
70
- <button
71
- onMouseEnter={() => setSubmenu('question')}
72
- onClick={() => setSubmenu(submenu === 'question' ? null : 'question')}
73
- className="w-full flex items-center justify-between gap-2 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
74
- >
75
- <span className="flex items-center gap-2"><MessageCircleQuestion size={15} className="text-blue-500" /> {labels.palette.question}</span>
76
- <ChevronRight size={13} className="text-gray-400" />
77
- </button>
78
- {submenu === 'question' && (
79
- <div className="absolute left-full top-0 ml-1 w-56 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-lg py-1">
80
- {QUESTION_TYPES.map((qt) => (
81
- <button
82
- key={qt}
83
- onClick={() => select({ kind: 'question', questionType: qt })}
84
- className="w-full text-left px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
85
- >
86
- {labels.questionTypeLabels[qt]}
87
- </button>
88
- ))}
89
- </div>
90
- )}
91
- </div>
92
-
93
- <button
94
- onMouseEnter={() => setSubmenu(null)}
95
- onClick={() => select({ kind: 'decision' })}
96
- className="w-full flex items-center gap-2 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
97
- >
98
- <GitBranch size={15} className="text-purple-500" /> {labels.palette.decision}
99
- </button>
100
-
101
- <button
102
- onMouseEnter={() => setSubmenu(null)}
103
- onClick={() => select({ kind: 'condition' })}
104
- className="w-full flex items-center gap-2 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
105
- title={labels.palette.conditionHint}
106
- >
107
- <Diamond size={15} className="text-cyan-500" /> {labels.palette.condition}
108
- </button>
109
-
110
- <div className="relative">
111
- <button
112
- onMouseEnter={() => setSubmenu('action')}
113
- onClick={() => setSubmenu(submenu === 'action' ? null : 'action')}
114
- className="w-full flex items-center justify-between gap-2 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
115
- >
116
- <span className="flex items-center gap-2"><Zap size={15} className="text-orange-500" /> {labels.palette.action}</span>
117
- <ChevronRight size={13} className="text-gray-400" />
118
- </button>
119
- {submenu === 'action' && (
120
- <div className="absolute left-full top-0 ml-1 w-64 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-lg py-1">
121
- {resolvedActionOptions.map((option) => (
122
- <button
123
- key={option.actionKind}
124
- onClick={() => select({ kind: 'action', actionKind: option.actionKind })}
125
- className="w-full text-left px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
126
- >
127
- {option.label}
128
- </button>
129
- ))}
130
- </div>
131
- )}
132
- </div>
165
+ <FlowPaletteMenu
166
+ onSelect={select}
167
+ labels={labels}
168
+ {...(actionOptions ? { actionOptions } : {})}
169
+ />
133
170
  </div>
134
171
  )}
135
172
  </div>
@@ -16,7 +16,7 @@ export function FlowPortalNode({ data }: NodeProps) {
16
16
  return (
17
17
  <button
18
18
  type="button"
19
- title={labels.crossFlowPortal.tooltip}
19
+ data-cv-tooltip={labels.crossFlowPortal.tooltip} aria-label={labels.crossFlowPortal.tooltip}
20
20
  onClick={onNavigate}
21
21
  className="relative flex items-center gap-1.5 rounded-full border-2 border-dashed border-cyan-400 dark:border-cyan-600 bg-cyan-50 dark:bg-cyan-950/40 px-3 py-1.5 text-xs font-medium text-cyan-700 dark:text-cyan-300 hover:bg-cyan-100 dark:hover:bg-cyan-950/70 transition-colors cursor-pointer"
22
22
  >
@@ -13,7 +13,11 @@ export interface FlowWhatsAppPreviewProps {
13
13
  // opções, botões (≤3) ou lista (4+) — para o editor mostrar exatamente o que o cliente verá.
14
14
  // Consome a mesma bolha/formatação do pacote (parseWhatsAppFormatting, T6.5) — T7.3 elimina a
15
15
  // duplicação de estilo que existia entre este preview e o MessageBubble do bot.
16
- export function FlowWhatsAppPreview({ body, options, labels = DEFAULT_FLOW_EDITOR_LABELS.nodePanel }: FlowWhatsAppPreviewProps) {
16
+ export function FlowWhatsAppPreview({
17
+ body,
18
+ options,
19
+ labels = DEFAULT_FLOW_EDITOR_LABELS.nodePanel,
20
+ }: FlowWhatsAppPreviewProps) {
17
21
  if (!body && (!options || options.length === 0)) {
18
22
  return <p className="text-xs text-gray-400 dark:text-gray-500 italic px-1">{labels.previewPlaceholder}</p>
19
23
  }
@@ -25,7 +29,11 @@ export function FlowWhatsAppPreview({ body, options, labels = DEFAULT_FLOW_EDITO
25
29
  <div className="rounded-xl bg-[#e5ddd5] dark:bg-gray-900 p-3 space-y-1.5">
26
30
  <div className="max-w-[85%]">
27
31
  <div className="rounded-lg rounded-tl-none bg-white dark:bg-gray-700 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 shadow-sm whitespace-pre-wrap break-words">
28
- {body ? parseWhatsAppFormatting(body) : <span className="italic text-gray-400">{labels.previewEmptyBody}</span>}
32
+ {body ? (
33
+ parseWhatsAppFormatting(body)
34
+ ) : (
35
+ <span className="italic text-gray-400">{labels.previewEmptyBody}</span>
36
+ )}
29
37
  {hasOptions && !usesButtons && (
30
38
  <div className="mt-2 -mx-3 -mb-2 border-t border-gray-100 dark:border-gray-600">
31
39
  <div className="flex items-center justify-center gap-1.5 py-2 text-sm font-medium text-cyan-600 dark:text-cyan-400">
@@ -38,7 +46,10 @@ export function FlowWhatsAppPreview({ body, options, labels = DEFAULT_FLOW_EDITO
38
46
  {hasOptions && usesButtons && (
39
47
  <div className="mt-1 space-y-1">
40
48
  {options!.map(([id, label]) => (
41
- <div key={id} className="rounded-lg bg-white dark:bg-gray-700 py-1.5 text-center text-sm font-medium text-cyan-600 dark:text-cyan-400 shadow-sm">
49
+ <div
50
+ key={id}
51
+ className="rounded-lg bg-white dark:bg-gray-700 py-1.5 text-center text-sm font-medium text-cyan-600 dark:text-cyan-400 shadow-sm"
52
+ >
42
53
  {label || <span className="italic text-gray-400">{labels.previewEmptyOption}</span>}
43
54
  </div>
44
55
  ))}