@adatechnology/conversations-ui 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/dist/channel/index.d.ts +2 -0
  2. package/dist/channel/index.js +0 -0
  3. package/dist/chunk-ZDURDZTM.js +199 -0
  4. package/dist/flows/index.d.ts +246 -0
  5. package/dist/flows/index.js +1110 -0
  6. package/dist/index.d.ts +542 -0
  7. package/dist/index.js +2077 -0
  8. package/dist/styles.css +11 -0
  9. package/dist/styles.d.ts +2 -0
  10. package/package.json +52 -0
  11. package/src/AudioPlayer.tsx +103 -0
  12. package/src/Avatar.tsx +63 -0
  13. package/src/ConversationListItem.tsx +147 -0
  14. package/src/ConversationLocalesProvider.tsx +76 -0
  15. package/src/DateDivider.tsx +32 -0
  16. package/src/EmojiPicker.tsx +77 -0
  17. package/src/FileIcon.tsx +33 -0
  18. package/src/Lightbox.tsx +17 -0
  19. package/src/MediaRenderer.tsx +158 -0
  20. package/src/MessageBubble.tsx +129 -0
  21. package/src/MessageComposer.tsx +211 -0
  22. package/src/MessageTail.tsx +18 -0
  23. package/src/MessageText.tsx +42 -0
  24. package/src/MessageTimestamp.tsx +22 -0
  25. package/src/SimpleEmojiPicker.tsx +72 -0
  26. package/src/StatusTicks.tsx +39 -0
  27. package/src/Toast.tsx +140 -0
  28. package/src/Wallpaper.tsx +13 -0
  29. package/src/WhatsAppMessageEditor.tsx +142 -0
  30. package/src/channel/index.ts +1 -0
  31. package/src/conversations/index.ts +1 -0
  32. package/src/flows/FlowGroupFrame.tsx +21 -0
  33. package/src/flows/FlowGroupHeader.tsx +31 -0
  34. package/src/flows/FlowMapCanvas.tsx +76 -0
  35. package/src/flows/FlowMapNode.tsx +39 -0
  36. package/src/flows/FlowNodeCard.tsx +150 -0
  37. package/src/flows/FlowNodePanel.tsx +356 -0
  38. package/src/flows/FlowPalette.tsx +137 -0
  39. package/src/flows/FlowPortalNode.tsx +30 -0
  40. package/src/flows/FlowWhatsAppPreview.tsx +67 -0
  41. package/src/flows/flowGraph.ts +391 -0
  42. package/src/flows/index.ts +55 -0
  43. package/src/flows/labels.ts +187 -0
  44. package/src/hooks/useAsyncResource.ts +38 -0
  45. package/src/hooks/useConversationContext.ts +23 -0
  46. package/src/hooks/useConversationDocuments.ts +33 -0
  47. package/src/hooks/useConversationList.ts +32 -0
  48. package/src/hooks/useConversationMessages.ts +64 -0
  49. package/src/hooks/useConversationRealtime.ts +50 -0
  50. package/src/index.ts +87 -0
  51. package/src/lib/format.ts +32 -0
  52. package/src/lib/phone.ts +26 -0
  53. package/src/lib/whatsapp-formatting.tsx +215 -0
  54. package/src/providers/ConversationsProvider.tsx +29 -0
  55. package/src/providers/types.ts +54 -0
  56. package/src/settings/TopicsForm.tsx +109 -0
  57. package/src/settings/WelcomeFarewellForm.tsx +118 -0
  58. package/src/settings/WhatsAppCreateTemplateForm.tsx +309 -0
  59. package/src/settings/WhatsAppTemplateSettingsForm.tsx +264 -0
  60. package/src/styles.css +21 -0
  61. package/src/theme.ts +30 -0
  62. package/src/types.ts +44 -0
  63. package/src/useDarkMode.ts +48 -0
  64. package/src/useWaitingNotifications.ts +64 -0
  65. package/tsconfig.json +15 -0
@@ -0,0 +1,356 @@
1
+ import { useState } from 'react'
2
+ import { Plus, Trash2, Save, X, AlertTriangle, AlertCircle } from 'lucide-react'
3
+ import { FlowWhatsAppPreview } from './FlowWhatsAppPreview'
4
+ import { nodeLabel } from './FlowNodeCard'
5
+ import { CROSS_FLOW_PREFIX, CONDITION_OPERATORS } from './flowGraph'
6
+ import { DEFAULT_FLOW_EDITOR_LABELS, type FlowEditorLabels } from './labels'
7
+ import type { FlowGraphData, FlowNodeData, GraphIssue } from './flowGraph'
8
+
9
+ const SELECT_CLASSNAME =
10
+ 'border border-gray-200 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 rounded-xl px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400 focus:border-transparent transition-all'
11
+ const INPUT_CLASSNAME =
12
+ 'border border-gray-200 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 rounded-xl px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400 focus:border-transparent transition-all'
13
+
14
+ function truncateLabel(value: string, max = 60): string {
15
+ if (!value) return '—'
16
+ return value.length > max ? `${value.slice(0, max - 1)}…` : value
17
+ }
18
+
19
+ function WhatsAppTextField({
20
+ label,
21
+ value,
22
+ onValueChange,
23
+ options,
24
+ placeholder,
25
+ labels,
26
+ }: {
27
+ label: string
28
+ value: string
29
+ onValueChange: (value: string) => void
30
+ options?: [string, string][]
31
+ placeholder?: string
32
+ labels: FlowEditorLabels
33
+ }) {
34
+ return (
35
+ <div className="space-y-2">
36
+ <div>
37
+ <label className="text-xs font-medium text-gray-500 dark:text-gray-400">{label}</label>
38
+ <textarea
39
+ value={value}
40
+ onChange={(e) => onValueChange(e.target.value)}
41
+ rows={3}
42
+ placeholder={placeholder}
43
+ className={`w-full mt-1 ${INPUT_CLASSNAME}`}
44
+ />
45
+ </div>
46
+ <div>
47
+ <label className="text-xs font-medium text-gray-500 dark:text-gray-400">{labels.nodePanel.preview}</label>
48
+ <div className="mt-1">
49
+ <FlowWhatsAppPreview body={value} options={options} labels={labels.nodePanel} />
50
+ </div>
51
+ </div>
52
+ </div>
53
+ )
54
+ }
55
+
56
+ function IssueRow({ issue }: { issue: GraphIssue }) {
57
+ const Icon = issue.severity === 'error' ? AlertCircle : AlertTriangle
58
+ const color = issue.severity === 'error' ? 'text-red-600 dark:text-red-400' : 'text-amber-600 dark:text-amber-400'
59
+ return (
60
+ <div className={`flex items-start gap-1.5 text-xs ${color}`}>
61
+ <Icon size={13} className="mt-0.5 shrink-0" />
62
+ <span>{issue.message}</span>
63
+ </div>
64
+ )
65
+ }
66
+
67
+ export interface FlowNodePanelProps {
68
+ graph: FlowGraphData
69
+ node: FlowNodeData
70
+ issues: GraphIssue[]
71
+ otherFlows: { key: string; label: string }[]
72
+ onClose: () => void
73
+ onChange: (updated: FlowNodeData) => void
74
+ onDelete: (nodeId: string) => void
75
+ labels?: Partial<FlowEditorLabels>
76
+ }
77
+
78
+ // Paridade com financiamento-imobiliario-bot/apps/web/src/components/flows/FlowNodePanel.tsx —
79
+ // painel lateral de edição de um nó do fluxo (pergunta, decisão, ação ou condição).
80
+ export function FlowNodePanel({
81
+ graph,
82
+ node,
83
+ issues,
84
+ otherFlows,
85
+ onClose,
86
+ onChange,
87
+ onDelete,
88
+ labels: labelsOverride,
89
+ }: FlowNodePanelProps) {
90
+ const labels = { ...DEFAULT_FLOW_EDITOR_LABELS, ...labelsOverride }
91
+ const [draft, setDraft] = useState<FlowNodeData>(node)
92
+ const otherNodeIds = Object.keys(graph.nodes).filter((id) => id !== node.id)
93
+ const isFixedLogic = draft.type === 'entrada_choice'
94
+ const isAction = draft.type === 'action'
95
+ const isCondition = draft.type === 'condition'
96
+ const isStart = graph.startNodeId === node.id
97
+ const nodeIssues = issues.filter((i) => i.nodeId === node.id)
98
+ // Chaves já usadas por perguntas deste fluxo — sugestão pro campo de variável da condição,
99
+ // 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))]
101
+ const conditionAnswerIds = isCondition ? ['true', 'false'] : (draft.options ?? []).map(([id]) => id)
102
+
103
+ function updateNextString(value: string) {
104
+ setDraft((prev) => ({ ...prev, next: value }))
105
+ }
106
+
107
+ function updateNextByAnswer(answerId: string, value: string) {
108
+ setDraft((prev) => {
109
+ const current = typeof prev.next === 'object' && prev.next ? prev.next : { byAnswer: {}, default: otherNodeIds[0] ?? '' }
110
+ return { ...prev, next: { ...current, byAnswer: { ...current.byAnswer, [answerId]: value } } }
111
+ })
112
+ }
113
+
114
+ function updateNextDefault(value: string) {
115
+ setDraft((prev) => {
116
+ const current = typeof prev.next === 'object' && prev.next ? prev.next : { byAnswer: {}, default: value }
117
+ return { ...prev, next: { ...current, default: value } }
118
+ })
119
+ }
120
+
121
+ function updateOption(index: number, field: 0 | 1, value: string) {
122
+ setDraft((prev) => {
123
+ const opts = [...(prev.options ?? [])]
124
+ const opt: [string, string] = [...opts[index]!] as [string, string]
125
+ opt[field] = value
126
+ opts[index] = opt
127
+ return { ...prev, options: opts }
128
+ })
129
+ }
130
+
131
+ function addOption() {
132
+ setDraft((prev) => ({ ...prev, options: [...(prev.options ?? []), [String((prev.options?.length ?? 0) + 1), 'Nova opção']] }))
133
+ }
134
+
135
+ function removeOption(index: number) {
136
+ setDraft((prev) => ({ ...prev, options: (prev.options ?? []).filter((_, i) => i !== index) }))
137
+ }
138
+
139
+ function nextNodeOptions() {
140
+ return (
141
+ <>
142
+ {otherNodeIds.map((id) => (
143
+ <option key={id} value={id}>{truncateLabel(nodeLabel(graph.nodes[id], labels))}</option>
144
+ ))}
145
+ {otherFlows.length > 0 && (
146
+ <optgroup label={labels.nodePanel.otherFlowsGroup}>
147
+ {otherFlows.map((flow) => (
148
+ <option key={flow.key} value={`${CROSS_FLOW_PREFIX}${flow.key}`}>{flow.label}</option>
149
+ ))}
150
+ </optgroup>
151
+ )}
152
+ </>
153
+ )
154
+ }
155
+
156
+ return (
157
+ <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
+ <div className="flex items-center justify-between px-4 py-3 border-b border-gray-100 dark:border-gray-700">
159
+ <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>
161
+ </div>
162
+
163
+ <div className="flex-1 overflow-y-auto p-4 space-y-4">
164
+ {nodeIssues.length > 0 && (
165
+ <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} />)}
167
+ </div>
168
+ )}
169
+
170
+ {isFixedLogic && (
171
+ <p className="text-xs text-purple-700 dark:text-purple-400 bg-purple-50 dark:bg-purple-950/30 rounded-lg p-2">
172
+ {labels.nodePanel.fixedLogicNotice}
173
+ </p>
174
+ )}
175
+ {isAction && (
176
+ <p className="text-xs text-orange-700 dark:text-orange-400 bg-orange-50 dark:bg-orange-950/30 rounded-lg p-2">
177
+ {labels.nodePanel.actionNotice}
178
+ </p>
179
+ )}
180
+ {isCondition && (
181
+ <p className="text-xs text-cyan-700 dark:text-cyan-400 bg-cyan-50 dark:bg-cyan-950/30 rounded-lg p-2">
182
+ {labels.nodePanel.conditionNotice}
183
+ </p>
184
+ )}
185
+
186
+ {draft.contextKey && (
187
+ <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" />
190
+ </div>
191
+ )}
192
+
193
+ {!isFixedLogic && !isAction && !isCondition && (
194
+ <div>
195
+ <label className="text-xs font-medium text-gray-500 dark:text-gray-400">{labels.nodePanel.questionType}</label>
196
+ <select
197
+ value={draft.questionType ?? 'text'}
198
+ onChange={(e) => setDraft((prev) => ({ ...prev, questionType: e.target.value as FlowNodeData['questionType'] }))}
199
+ className={`w-full mt-1 ${SELECT_CLASSNAME}`}
200
+ >
201
+ {Object.entries(labels.questionTypeLabels).map(([key, label]) => (
202
+ <option key={key} value={key}>{label}</option>
203
+ ))}
204
+ </select>
205
+ </div>
206
+ )}
207
+
208
+ {!isFixedLogic && !isAction && !isCondition && (
209
+ <WhatsAppTextField
210
+ label={labels.nodePanel.question}
211
+ value={draft.question ?? ''}
212
+ options={draft.questionType === 'choice' ? draft.options : undefined}
213
+ onValueChange={(value) => setDraft((prev) => ({ ...prev, question: value }))}
214
+ labels={labels}
215
+ />
216
+ )}
217
+
218
+ {isCondition && (
219
+ <div className="space-y-3">
220
+ <div>
221
+ <label className="text-xs font-medium text-gray-500 dark:text-gray-400">{labels.nodePanel.conditionVariable}</label>
222
+ <input
223
+ value={draft.conditionContextKey ?? ''}
224
+ onChange={(e) => setDraft((prev) => ({ ...prev, conditionContextKey: e.target.value }))}
225
+ list="condition-context-keys"
226
+ className={`w-full mt-1 ${INPUT_CLASSNAME}`}
227
+ />
228
+ <datalist id="condition-context-keys">
229
+ {knownContextKeys.map((key) => <option key={key} value={key} />)}
230
+ </datalist>
231
+ </div>
232
+ <div>
233
+ <label className="text-xs font-medium text-gray-500 dark:text-gray-400">{labels.nodePanel.conditionOperator}</label>
234
+ <select
235
+ value={draft.conditionOperator ?? '>'}
236
+ onChange={(e) => setDraft((prev) => ({ ...prev, conditionOperator: e.target.value as FlowNodeData['conditionOperator'] }))}
237
+ className={`w-full mt-1 ${SELECT_CLASSNAME}`}
238
+ >
239
+ {CONDITION_OPERATORS.map((operator) => (
240
+ <option key={operator} value={operator}>{labels.conditionOperatorLabels[operator] ?? operator}</option>
241
+ ))}
242
+ </select>
243
+ </div>
244
+ <div>
245
+ <label className="text-xs font-medium text-gray-500 dark:text-gray-400">{labels.nodePanel.conditionValue}</label>
246
+ <input
247
+ value={draft.conditionValue ?? ''}
248
+ onChange={(e) => setDraft((prev) => ({ ...prev, conditionValue: e.target.value }))}
249
+ className={`w-full mt-1 ${INPUT_CLASSNAME}`}
250
+ />
251
+ </div>
252
+ </div>
253
+ )}
254
+
255
+ {isAction && draft.actionKind === 'send_product_list' && (
256
+ <WhatsAppTextField
257
+ label={labels.nodePanel.fallbackMessage}
258
+ value={draft.fallbackMessage ?? ''}
259
+ onValueChange={(value) => setDraft((prev) => ({ ...prev, fallbackMessage: value }))}
260
+ placeholder="(usa a mensagem padrão de fallback se vazio)"
261
+ labels={labels}
262
+ />
263
+ )}
264
+
265
+ {isAction && draft.actionKind !== 'send_product_list' && (
266
+ <WhatsAppTextField
267
+ label={labels.nodePanel.directMessage}
268
+ value={draft.directMessage ?? ''}
269
+ onValueChange={(value) => setDraft((prev) => ({ ...prev, directMessage: value }))}
270
+ placeholder="(usa a mensagem padrão de encaminhamento se vazio)"
271
+ labels={labels}
272
+ />
273
+ )}
274
+
275
+ {(draft.questionType === 'choice' || draft.type === 'menu') && (
276
+ <div>
277
+ <div className="flex items-center justify-between mb-1.5">
278
+ <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">
280
+ <Plus size={12} /> {labels.nodePanel.addOption}
281
+ </button>
282
+ </div>
283
+ <div className="space-y-2">
284
+ {(draft.options ?? []).map(([id, label], i) => (
285
+ <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>
291
+ </div>
292
+ ))}
293
+ </div>
294
+ </div>
295
+ )}
296
+
297
+ {!isAction && (
298
+ <div className="pt-2 border-t border-gray-100 dark:border-gray-700">
299
+ <label className="text-xs font-medium text-gray-500 dark:text-gray-400">{labels.nodePanel.next}</label>
300
+ <p className="text-[11px] text-gray-400 dark:text-gray-500 mb-1">{labels.nodePanel.nextHint}</p>
301
+ {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}`}>
304
+ <option value="">—</option>
305
+ {nextNodeOptions()}
306
+ </select>
307
+ ) : (
308
+ <div className="space-y-2 mt-1">
309
+ {conditionAnswerIds.map((id) => (
310
+ <div key={id} className="flex items-center gap-2">
311
+ <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)}
313
+ </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}`}>
316
+ <option value="">—</option>
317
+ {nextNodeOptions()}
318
+ </select>
319
+ </div>
320
+ ))}
321
+ <div className="flex items-center gap-2">
322
+ <span className="text-xs text-gray-500 w-32 shrink-0">
323
+ {isCondition ? labels.nodePanel.conditionVariableMissing : labels.nodePanel.nextDefault}
324
+ </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}`}>
327
+ <option value="">—</option>
328
+ {nextNodeOptions()}
329
+ </select>
330
+ </div>
331
+ </div>
332
+ )}
333
+ </div>
334
+ )}
335
+ </div>
336
+
337
+ <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">
339
+ <Save size={14} /> {labels.nodePanel.save}
340
+ </button>
341
+ {!isStart && (
342
+ <button
343
+ onClick={() => { if (window.confirm(labels.nodePanel.deleteConfirm)) onDelete(node.id) }}
344
+ title={labels.nodePanel.delete}
345
+ 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
+ >
347
+ <Trash2 size={14} />
348
+ </button>
349
+ )}
350
+ <button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 dark:text-gray-300 hover:underline">
351
+ {labels.nodePanel.cancel}
352
+ </button>
353
+ </div>
354
+ </div>
355
+ )
356
+ }
@@ -0,0 +1,137 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+ import { Plus, MessageCircleQuestion, GitBranch, Zap, Diamond, ChevronRight } from 'lucide-react'
3
+ import { DEFAULT_FLOW_EDITOR_LABELS, type FlowEditorLabels } from './labels'
4
+ import type { FlowActionKind, FlowQuestionType } from './flowGraph'
5
+
6
+ export type NewNodeSpec =
7
+ | { kind: 'question'; questionType: FlowQuestionType }
8
+ | { kind: 'decision' }
9
+ | { kind: 'condition' }
10
+ | { kind: 'action'; actionKind: FlowActionKind }
11
+
12
+ export interface FlowPaletteActionOption {
13
+ actionKind: FlowActionKind
14
+ label: string
15
+ }
16
+
17
+ export interface FlowPaletteProps {
18
+ onAdd: (spec: NewNodeSpec) => void
19
+ labels?: Partial<FlowEditorLabels>
20
+ // Kinds de ação oferecidos no submenu "Ação" — o host declara os próprios (ex.: o bot
21
+ // registraria 'trigger_simulation' aqui). Sem isso, o pacote não assume nenhum caso de
22
+ // negócio específico além do genérico 'handoff'.
23
+ actionOptions?: FlowPaletteActionOption[]
24
+ }
25
+
26
+ const QUESTION_TYPES: FlowQuestionType[] = ['text', 'money', 'date', 'int', 'cpf']
27
+
28
+ // Paridade com financiamento-imobiliario-bot/apps/web/src/components/flows/FlowPalette.tsx —
29
+ // botão "Adicionar" com submenus por categoria (Pergunta/Decisão/Condição/Ação). Sem
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 }
33
+ const resolvedActionOptions = actionOptions ?? [
34
+ { actionKind: 'handoff', label: labels.actionKindLabels.handoff ?? 'Encaminhar para atendimento' },
35
+ ]
36
+
37
+ const [open, setOpen] = useState(false)
38
+ const [submenu, setSubmenu] = useState<'question' | 'action' | null>(null)
39
+ const containerRef = useRef<HTMLDivElement>(null)
40
+
41
+ useEffect(() => {
42
+ function handleClickOutside(event: MouseEvent) {
43
+ if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
44
+ setOpen(false)
45
+ setSubmenu(null)
46
+ }
47
+ }
48
+ document.addEventListener('mousedown', handleClickOutside)
49
+ return () => document.removeEventListener('mousedown', handleClickOutside)
50
+ }, [])
51
+
52
+ function select(spec: NewNodeSpec) {
53
+ onAdd(spec)
54
+ setOpen(false)
55
+ setSubmenu(null)
56
+ }
57
+
58
+ return (
59
+ <div ref={containerRef} className="relative">
60
+ <button
61
+ onClick={() => setOpen((v) => !v)}
62
+ 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
+ >
64
+ <Plus size={14} /> {labels.palette.title}
65
+ </button>
66
+
67
+ {open && (
68
+ <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>
133
+ </div>
134
+ )}
135
+ </div>
136
+ )
137
+ }
@@ -0,0 +1,30 @@
1
+ import { Handle, Position, type NodeProps } from '@xyflow/react'
2
+ import { ArrowUpRight } from 'lucide-react'
3
+ import { DEFAULT_FLOW_EDITOR_LABELS, type FlowEditorLabels } from './labels'
4
+
5
+ export type FlowPortalNodeData = {
6
+ label: string
7
+ labels?: FlowEditorLabels
8
+ onNavigate: () => void
9
+ }
10
+
11
+ // Pseudo-nó: representa visualmente um salto "flow:<key>" para outro fluxo, que não tem um nó
12
+ // real para desenhar a ligação dentro deste grafo. Clicar nele navega o editor pro fluxo alvo.
13
+ export function FlowPortalNode({ data }: NodeProps) {
14
+ const { label, labels = DEFAULT_FLOW_EDITOR_LABELS, onNavigate } = data as unknown as FlowPortalNodeData
15
+
16
+ return (
17
+ <button
18
+ type="button"
19
+ title={labels.crossFlowPortal.tooltip}
20
+ onClick={onNavigate}
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
+ >
23
+ <Handle type="target" position={Position.Top} id="target" className="!bg-cyan-400 dark:!bg-cyan-600" />
24
+ <ArrowUpRight size={12} strokeWidth={2.5} />
25
+ {labels.crossFlowPortal.goesTo(label)}
26
+ </button>
27
+ )
28
+ }
29
+
30
+ export const flowPortalNodeTypes = { flowPortal: FlowPortalNode }
@@ -0,0 +1,67 @@
1
+ import { List } from 'lucide-react'
2
+ import { parseWhatsAppFormatting } from '../lib/whatsapp-formatting'
3
+ import { DEFAULT_FLOW_EDITOR_LABELS, type FlowEditorLabels } from './labels'
4
+ import { rendersAsButtons, WHATSAPP_LIMITS } from './flowGraph'
5
+
6
+ export interface FlowWhatsAppPreviewProps {
7
+ body: string
8
+ options?: [string, string][]
9
+ labels?: FlowEditorLabels['nodePanel']
10
+ }
11
+
12
+ // Espelha como o WhatsApp REALMENTE renderiza a mensagem do nó: bolha de texto e, quando há
13
+ // opções, botões (≤3) ou lista (4+) — para o editor mostrar exatamente o que o cliente verá.
14
+ // Consome a mesma bolha/formatação do pacote (parseWhatsAppFormatting, T6.5) — T7.3 elimina a
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) {
17
+ if (!body && (!options || options.length === 0)) {
18
+ return <p className="text-xs text-gray-400 dark:text-gray-500 italic px-1">{labels.previewPlaceholder}</p>
19
+ }
20
+
21
+ const usesButtons = rendersAsButtons(options)
22
+ const hasOptions = (options?.length ?? 0) > 0
23
+
24
+ return (
25
+ <div className="rounded-xl bg-[#e5ddd5] dark:bg-gray-900 p-3 space-y-1.5">
26
+ <div className="max-w-[85%]">
27
+ <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>}
29
+ {hasOptions && !usesButtons && (
30
+ <div className="mt-2 -mx-3 -mb-2 border-t border-gray-100 dark:border-gray-600">
31
+ <div className="flex items-center justify-center gap-1.5 py-2 text-sm font-medium text-cyan-600 dark:text-cyan-400">
32
+ <List size={15} /> {labels.previewListButton}
33
+ </div>
34
+ </div>
35
+ )}
36
+ </div>
37
+
38
+ {hasOptions && usesButtons && (
39
+ <div className="mt-1 space-y-1">
40
+ {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">
42
+ {label || <span className="italic text-gray-400">{labels.previewEmptyOption}</span>}
43
+ </div>
44
+ ))}
45
+ </div>
46
+ )}
47
+
48
+ {hasOptions && !usesButtons && (
49
+ <div className="mt-1.5 rounded-lg bg-white dark:bg-gray-700 shadow-sm divide-y divide-gray-100 dark:divide-gray-600 overflow-hidden">
50
+ {options!.slice(0, WHATSAPP_LIMITS.MAX_LIST_ROWS).map(([id, label]) => (
51
+ <div key={id} className="flex items-center gap-2 px-3 py-1.5 text-sm text-gray-800 dark:text-gray-100">
52
+ <span className="h-3.5 w-3.5 rounded-full border border-gray-300 dark:border-gray-500 shrink-0" />
53
+ {label || <span className="italic text-gray-400">{labels.previewEmptyOption}</span>}
54
+ </div>
55
+ ))}
56
+ </div>
57
+ )}
58
+ </div>
59
+
60
+ {hasOptions && (
61
+ <p className="text-[11px] text-gray-500 dark:text-gray-400 px-1">
62
+ {usesButtons ? labels.previewModeButtons : labels.previewModeList}
63
+ </p>
64
+ )}
65
+ </div>
66
+ )
67
+ }