@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.
- package/dist/ConversationSimulatorPanel--5fIzXWY.d.ts +804 -0
- package/dist/{chunk-G5BM3VBP.js → chunk-BJNRLLDO.js} +1248 -282
- package/dist/chunk-DKPXKQGC.js +110 -0
- package/dist/{chunk-2AYDBWNE.js → chunk-WCBDXZ3X.js} +13 -3
- package/dist/flows/index.d.ts +422 -5
- package/dist/flows/index.js +2502 -676
- package/dist/index.d.ts +919 -17
- package/dist/index.js +3676 -675
- package/dist/preview/index.d.ts +62 -105
- package/dist/preview/index.js +162 -284
- package/dist/styles.css +893 -0
- package/package.json +9 -8
- package/src/AudioPlayer.tsx +8 -0
- package/src/AudioRecorderButton.test.tsx +30 -0
- package/src/AudioRecorderButton.tsx +248 -0
- package/src/AudioTranscription.test.tsx +115 -0
- package/src/AudioTranscription.tsx +252 -0
- package/src/Avatar.tsx +1 -1
- package/src/ConversationContextPanel.tsx +218 -44
- package/src/ConversationDocumentsPanel.tsx +11 -6
- package/src/ConversationHeader.test.tsx +66 -0
- package/src/ConversationHeader.tsx +147 -47
- package/src/ConversationListItem.tsx +8 -6
- package/src/ConversationLocalesProvider.tsx +28 -0
- package/src/ConversationRow.tsx +53 -7
- package/src/DarkModeToggle.test.tsx +76 -0
- package/src/DarkModeToggle.tsx +92 -0
- package/src/DocumentsLibrary.tsx +67 -7
- package/src/EmojiPicker.tsx +2 -1
- package/src/InteractiveMessage.tsx +3 -0
- package/src/Lightbox.tsx +1 -1
- package/src/MediaRenderer.tsx +88 -15
- package/src/MessageBubble.test.tsx +41 -0
- package/src/MessageBubble.tsx +47 -5
- package/src/MessageComposer.test.tsx +35 -0
- package/src/MessageComposer.tsx +122 -17
- package/src/MessageText.tsx +2 -1
- package/src/MessageTimestamp.tsx +2 -1
- package/src/RichMessageComposer.test.tsx +113 -0
- package/src/RichMessageComposer.tsx +551 -0
- package/src/SimpleEmojiPicker.tsx +5 -3
- package/src/StatusTicks.tsx +1 -1
- package/src/Toast.tsx +4 -0
- package/src/Tooltip.test.ts +42 -0
- package/src/Tooltip.tsx +167 -0
- package/src/Wallpaper.tsx +27 -13
- package/src/WhatsAppMessageEditor.tsx +10 -7
- package/src/WindowExpiredNotice.tsx +12 -4
- package/src/{preview/audioRecorderFormat.test.ts → audioRecorderFormat.test.ts} +1 -1
- package/src/buildOutput.test.ts +79 -0
- package/src/composer.constant.ts +33 -0
- package/src/conversationTranscript.test.ts +57 -0
- package/src/conversationTranscript.ts +29 -4
- package/src/conversationWindow.ts +7 -5
- package/src/documentTypeLabel.test.ts +57 -0
- package/src/documents/DocumentsWorkspace.tsx +550 -0
- package/src/documents/index.ts +8 -0
- package/src/documents/labels.ts +92 -0
- package/src/flows/FlowConnectionEdge.tsx +104 -0
- package/src/flows/FlowGroupHeader.tsx +12 -2
- package/src/flows/FlowLegend.tsx +125 -0
- package/src/flows/FlowMapCanvas.tsx +15 -12
- package/src/flows/FlowMapNode.tsx +4 -1
- package/src/flows/FlowNodeCard.tsx +219 -34
- package/src/flows/FlowNodePanel.tsx +153 -39
- package/src/flows/FlowPalette.tsx +156 -70
- package/src/flows/FlowPortalNode.tsx +1 -1
- package/src/flows/FlowWhatsAppPreview.tsx +14 -3
- package/src/flows/FlowsWorkspace.tsx +1255 -0
- package/src/flows/flowCanvasModel.test.ts +456 -0
- package/src/flows/flowCanvasModel.ts +378 -0
- package/src/flows/flowEditorOps.test.ts +276 -0
- package/src/flows/flowEditorOps.ts +202 -0
- package/src/flows/flowGraph.ts +78 -53
- package/src/flows/flowMenuPlacement.test.ts +130 -0
- package/src/flows/flowMenuPlacement.ts +86 -0
- package/src/flows/index.ts +51 -2
- package/src/flows/labels.ts +180 -0
- package/src/flows/workspaceContract.test.ts +126 -0
- package/src/hooks/useContainerWidth.ts +35 -0
- package/src/hooks/useConversationRealtime.ts +10 -8
- package/src/hooks/useScrollToLatestMessage.ts +127 -0
- package/src/hooks/useUrlFilterState.ts +107 -0
- package/src/icon.constant.ts +12 -0
- package/src/index.ts +100 -0
- package/src/lib/composer-formatting.test.ts +78 -0
- package/src/lib/composer-formatting.ts +145 -0
- package/src/lib/whatsapp-formatting.test.tsx +37 -0
- package/src/lib/whatsapp-formatting.tsx +28 -3
- package/src/listing/index.tsx +202 -0
- package/src/pagination.constant.ts +10 -0
- package/src/preview/ConversationPreview.tsx +84 -45
- package/src/preview/ConversationSimulatorClient.ts +143 -0
- package/src/preview/ConversationSimulatorPanel.test.tsx +55 -0
- package/src/preview/ConversationSimulatorPanel.tsx +131 -0
- package/src/preview/createPreviewBridgeClient.test.ts +92 -0
- package/src/preview/createPreviewBridgeClient.ts +124 -0
- package/src/preview/createPreviewMediaUploader.ts +82 -0
- package/src/preview/createPreviewWebhookClient.test.ts +96 -0
- package/src/preview/createPreviewWebhookClient.ts +99 -3
- package/src/preview/index.ts +36 -2
- package/src/preview/previewMediaUploader.test.ts +61 -0
- package/src/providers/ConversationsProvider.tsx +8 -6
- package/src/providers/types.ts +59 -2
- package/src/quickReply.test.ts +58 -0
- package/src/replyLatency.test.ts +71 -0
- package/src/replyLatency.ts +57 -0
- package/src/settings/MessagesWorkspace.tsx +571 -0
- package/src/settings/TopicsForm.tsx +2 -0
- package/src/settings/TranscriptionSettingsForm.test.tsx +81 -0
- package/src/settings/TranscriptionSettingsForm.tsx +190 -0
- package/src/settings/WelcomeFarewellForm.tsx +1 -0
- package/src/settings/WhatsAppCreateTemplateForm.tsx +1 -0
- package/src/settings/WhatsAppTemplateSettingsForm.tsx +5 -2
- package/src/settings/WhatsAppTemplatesSettings.test.tsx +61 -0
- package/src/settings/WhatsAppTemplatesSettings.tsx +22 -2
- package/src/styles.css +858 -0
- package/src/theme.ts +13 -0
- package/src/types.ts +26 -0
- package/src/workspace/BulkTemplateModal.tsx +132 -0
- package/src/workspace/ConversationPane.tsx +432 -0
- package/src/workspace/ConversationsInboxList.tsx +194 -0
- package/src/workspace/ConversationsWorkspace.tsx +423 -0
- package/src/workspace/index.ts +17 -0
- package/src/workspace/labels.test.ts +17 -0
- package/src/workspace/labels.ts +85 -0
- package/src/workspace/useConversationsInbox.ts +332 -0
- package/dist/types-B5C1DLu1.d.ts +0 -365
- package/src/preview/AudioRecorderButton.tsx +0 -117
|
@@ -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 = [
|
|
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 =
|
|
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) => ({
|
|
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}>
|
|
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}`}>
|
|
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"
|
|
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) =>
|
|
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">
|
|
189
|
-
|
|
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">
|
|
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) =>
|
|
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}>
|
|
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">
|
|
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) =>
|
|
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">
|
|
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) =>
|
|
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}>
|
|
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">
|
|
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
|
-
{
|
|
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
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
{
|
|
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
|
|
303
|
-
|
|
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
|
|
410
|
+
{isCondition
|
|
411
|
+
? id === 'true'
|
|
412
|
+
? labels.nodePanel.conditionTrue
|
|
413
|
+
: labels.nodePanel.conditionFalse
|
|
414
|
+
: labels.nodePanel.nextByAnswer(id)}
|
|
313
415
|
</span>
|
|
314
|
-
<select
|
|
315
|
-
|
|
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
|
|
326
|
-
|
|
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
|
|
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={() => {
|
|
344
|
-
|
|
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>
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { useEffect, useRef, useState } from 'react'
|
|
1
|
+
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
|
2
2
|
import { Plus, MessageCircleQuestion, GitBranch, Zap, Diamond, ChevronRight } from 'lucide-react'
|
|
3
|
+
|
|
4
|
+
import { placeFloatingPanel, type FloatingPlacement } from './flowMenuPlacement'
|
|
3
5
|
import { DEFAULT_FLOW_EDITOR_LABELS, type FlowEditorLabels } from './labels'
|
|
4
6
|
import type { FlowActionKind, FlowQuestionType } from './flowGraph'
|
|
5
7
|
|
|
@@ -28,21 +30,164 @@ const QUESTION_TYPES: FlowQuestionType[] = ['text', 'money', 'date', 'int', 'cpf
|
|
|
28
30
|
// Paridade com financiamento-imobiliario-bot/apps/web/src/components/flows/FlowPalette.tsx —
|
|
29
31
|
// botão "Adicionar" com submenus por categoria (Pergunta/Decisão/Condição/Ação). Sem
|
|
30
32
|
// dependência de dropdown externa — menu simples com fechamento por clique fora.
|
|
31
|
-
export
|
|
32
|
-
|
|
33
|
+
export interface FlowPaletteMenuProps {
|
|
34
|
+
onSelect: (spec: NewNodeSpec) => void
|
|
35
|
+
labels: FlowEditorLabels
|
|
36
|
+
actionOptions?: FlowPaletteActionOption[]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A lista de tipos de nó, sem o botão que a abre.
|
|
41
|
+
*
|
|
42
|
+
* Separada porque o "+" do card abre exatamente esta lista: duplicar os itens era garantir que um
|
|
43
|
+
* tipo novo aparecesse num caminho e faltasse no outro.
|
|
44
|
+
*/
|
|
45
|
+
export function FlowPaletteMenu({ onSelect, labels, actionOptions }: FlowPaletteMenuProps) {
|
|
33
46
|
const resolvedActionOptions = actionOptions ?? [
|
|
34
47
|
{ actionKind: 'handoff', label: labels.actionKindLabels.handoff ?? 'Encaminhar para atendimento' },
|
|
35
48
|
]
|
|
49
|
+
const [submenu, setSubmenu] = useState<'question' | 'action' | null>(null)
|
|
50
|
+
const questionTriggerRef = useRef<HTMLButtonElement>(null)
|
|
51
|
+
const actionTriggerRef = useRef<HTMLButtonElement>(null)
|
|
52
|
+
const submenuRef = useRef<HTMLDivElement>(null)
|
|
53
|
+
const [placement, setPlacement] = useState<FloatingPlacement | null>(null)
|
|
54
|
+
|
|
55
|
+
// `fixed` posicionado por medição, e não preso ao item por CSS: alinhado sempre à direita e ao
|
|
56
|
+
// topo do item, o submenu era cortado pela borda da tela — e sem rolagem os últimos itens ficavam
|
|
57
|
+
// inalcançáveis. Roda antes da pintura, então não pisca.
|
|
58
|
+
useLayoutEffect(() => {
|
|
59
|
+
if (!submenu) {
|
|
60
|
+
setPlacement(null)
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
const trigger = submenu === 'question' ? questionTriggerRef.current : actionTriggerRef.current
|
|
64
|
+
const panel = submenuRef.current
|
|
65
|
+
if (!trigger || !panel) return
|
|
66
|
+
const anchor = trigger.getBoundingClientRect()
|
|
67
|
+
setPlacement(
|
|
68
|
+
placeFloatingPanel({
|
|
69
|
+
anchor: { left: anchor.left, top: anchor.top, right: anchor.right, bottom: anchor.bottom },
|
|
70
|
+
panel: { width: panel.offsetWidth, height: panel.scrollHeight },
|
|
71
|
+
viewport: { width: window.innerWidth, height: window.innerHeight },
|
|
72
|
+
prefer: 'side',
|
|
73
|
+
}),
|
|
74
|
+
)
|
|
75
|
+
}, [submenu])
|
|
76
|
+
|
|
77
|
+
const submenuStyle = {
|
|
78
|
+
position: 'fixed' as const,
|
|
79
|
+
left: placement?.left ?? 0,
|
|
80
|
+
top: placement?.top ?? 0,
|
|
81
|
+
maxHeight: placement?.maxHeight,
|
|
82
|
+
overflowY: 'auto' as const,
|
|
83
|
+
// Até a medição terminar o painel existe mas não aparece — senão ele pisca um quadro na
|
|
84
|
+
// posição errada, que é justamente o salto que esta correção remove.
|
|
85
|
+
visibility: placement ? ('visible' as const) : ('hidden' as const),
|
|
86
|
+
}
|
|
36
87
|
|
|
88
|
+
function select(spec: NewNodeSpec) {
|
|
89
|
+
onSelect(spec)
|
|
90
|
+
setSubmenu(null)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<>
|
|
95
|
+
<div className="relative">
|
|
96
|
+
<button
|
|
97
|
+
ref={questionTriggerRef}
|
|
98
|
+
data-cv-tooltip={labels.palette.question} aria-label={labels.palette.question}
|
|
99
|
+
onMouseEnter={() => setSubmenu('question')}
|
|
100
|
+
onClick={() => setSubmenu(submenu === 'question' ? null : 'question')}
|
|
101
|
+
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"
|
|
102
|
+
>
|
|
103
|
+
<span className="flex items-center gap-2">
|
|
104
|
+
<MessageCircleQuestion size={15} className="text-blue-500" /> {labels.palette.question}
|
|
105
|
+
</span>
|
|
106
|
+
<ChevronRight size={13} className="text-gray-400" />
|
|
107
|
+
</button>
|
|
108
|
+
{submenu === 'question' && (
|
|
109
|
+
<div
|
|
110
|
+
ref={submenuRef}
|
|
111
|
+
style={submenuStyle}
|
|
112
|
+
className="z-50 w-56 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-lg py-1"
|
|
113
|
+
>
|
|
114
|
+
{QUESTION_TYPES.map((qt) => (
|
|
115
|
+
<button
|
|
116
|
+
data-cv-tooltip={labels.questionTypeLabels[qt]} aria-label={labels.questionTypeLabels[qt]}
|
|
117
|
+
key={qt}
|
|
118
|
+
onClick={() => select({ kind: 'question', questionType: qt })}
|
|
119
|
+
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"
|
|
120
|
+
>
|
|
121
|
+
{labels.questionTypeLabels[qt]}
|
|
122
|
+
</button>
|
|
123
|
+
))}
|
|
124
|
+
</div>
|
|
125
|
+
)}
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
<button
|
|
129
|
+
data-cv-tooltip={labels.palette.decision} aria-label={labels.palette.decision}
|
|
130
|
+
onMouseEnter={() => setSubmenu(null)}
|
|
131
|
+
onClick={() => select({ kind: 'decision' })}
|
|
132
|
+
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"
|
|
133
|
+
>
|
|
134
|
+
<GitBranch size={15} className="text-purple-500" /> {labels.palette.decision}
|
|
135
|
+
</button>
|
|
136
|
+
|
|
137
|
+
<button
|
|
138
|
+
onMouseEnter={() => setSubmenu(null)}
|
|
139
|
+
onClick={() => select({ kind: 'condition' })}
|
|
140
|
+
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"
|
|
141
|
+
data-cv-tooltip={labels.palette.conditionHint} aria-label={labels.palette.conditionHint}
|
|
142
|
+
>
|
|
143
|
+
<Diamond size={15} className="text-cyan-500" /> {labels.palette.condition}
|
|
144
|
+
</button>
|
|
145
|
+
|
|
146
|
+
<div className="relative">
|
|
147
|
+
<button
|
|
148
|
+
ref={actionTriggerRef}
|
|
149
|
+
data-cv-tooltip={labels.palette.action} aria-label={labels.palette.action}
|
|
150
|
+
onMouseEnter={() => setSubmenu('action')}
|
|
151
|
+
onClick={() => setSubmenu(submenu === 'action' ? null : 'action')}
|
|
152
|
+
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"
|
|
153
|
+
>
|
|
154
|
+
<span className="flex items-center gap-2">
|
|
155
|
+
<Zap size={15} className="text-orange-500" /> {labels.palette.action}
|
|
156
|
+
</span>
|
|
157
|
+
<ChevronRight size={13} className="text-gray-400" />
|
|
158
|
+
</button>
|
|
159
|
+
{submenu === 'action' && (
|
|
160
|
+
<div
|
|
161
|
+
ref={submenuRef}
|
|
162
|
+
style={submenuStyle}
|
|
163
|
+
className="z-50 w-64 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-lg py-1"
|
|
164
|
+
>
|
|
165
|
+
{resolvedActionOptions.map((option) => (
|
|
166
|
+
<button
|
|
167
|
+
data-cv-tooltip={option.label} aria-label={option.label}
|
|
168
|
+
key={option.actionKind}
|
|
169
|
+
onClick={() => select({ kind: 'action', actionKind: option.actionKind })}
|
|
170
|
+
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"
|
|
171
|
+
>
|
|
172
|
+
{option.label}
|
|
173
|
+
</button>
|
|
174
|
+
))}
|
|
175
|
+
</div>
|
|
176
|
+
)}
|
|
177
|
+
</div>
|
|
178
|
+
</>
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function FlowPalette({ onAdd, labels: labelsOverride, actionOptions }: FlowPaletteProps) {
|
|
183
|
+
const labels = { ...DEFAULT_FLOW_EDITOR_LABELS, ...labelsOverride }
|
|
37
184
|
const [open, setOpen] = useState(false)
|
|
38
|
-
const [submenu, setSubmenu] = useState<'question' | 'action' | null>(null)
|
|
39
185
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
40
186
|
|
|
41
187
|
useEffect(() => {
|
|
42
188
|
function handleClickOutside(event: MouseEvent) {
|
|
43
189
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
|
44
190
|
setOpen(false)
|
|
45
|
-
setSubmenu(null)
|
|
46
191
|
}
|
|
47
192
|
}
|
|
48
193
|
document.addEventListener('mousedown', handleClickOutside)
|
|
@@ -52,12 +197,12 @@ export function FlowPalette({ onAdd, labels: labelsOverride, actionOptions }: Fl
|
|
|
52
197
|
function select(spec: NewNodeSpec) {
|
|
53
198
|
onAdd(spec)
|
|
54
199
|
setOpen(false)
|
|
55
|
-
setSubmenu(null)
|
|
56
200
|
}
|
|
57
201
|
|
|
58
202
|
return (
|
|
59
203
|
<div ref={containerRef} className="relative">
|
|
60
204
|
<button
|
|
205
|
+
data-cv-tooltip={labels.palette.title} aria-label={labels.palette.title}
|
|
61
206
|
onClick={() => setOpen((v) => !v)}
|
|
62
207
|
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
208
|
>
|
|
@@ -66,70 +211,11 @@ export function FlowPalette({ onAdd, labels: labelsOverride, actionOptions }: Fl
|
|
|
66
211
|
|
|
67
212
|
{open && (
|
|
68
213
|
<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
|
-
<
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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>
|
|
214
|
+
<FlowPaletteMenu
|
|
215
|
+
onSelect={select}
|
|
216
|
+
labels={labels}
|
|
217
|
+
{...(actionOptions ? { actionOptions } : {})}
|
|
218
|
+
/>
|
|
133
219
|
</div>
|
|
134
220
|
)}
|
|
135
221
|
</div>
|
|
@@ -16,7 +16,7 @@ export function FlowPortalNode({ data }: NodeProps) {
|
|
|
16
16
|
return (
|
|
17
17
|
<button
|
|
18
18
|
type="button"
|
|
19
|
-
|
|
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
|
>
|