@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.
- package/dist/channel/index.d.ts +2 -0
- package/dist/channel/index.js +0 -0
- package/dist/chunk-ZDURDZTM.js +199 -0
- package/dist/flows/index.d.ts +246 -0
- package/dist/flows/index.js +1110 -0
- package/dist/index.d.ts +542 -0
- package/dist/index.js +2077 -0
- package/dist/styles.css +11 -0
- package/dist/styles.d.ts +2 -0
- package/package.json +52 -0
- package/src/AudioPlayer.tsx +103 -0
- package/src/Avatar.tsx +63 -0
- package/src/ConversationListItem.tsx +147 -0
- package/src/ConversationLocalesProvider.tsx +76 -0
- package/src/DateDivider.tsx +32 -0
- package/src/EmojiPicker.tsx +77 -0
- package/src/FileIcon.tsx +33 -0
- package/src/Lightbox.tsx +17 -0
- package/src/MediaRenderer.tsx +158 -0
- package/src/MessageBubble.tsx +129 -0
- package/src/MessageComposer.tsx +211 -0
- package/src/MessageTail.tsx +18 -0
- package/src/MessageText.tsx +42 -0
- package/src/MessageTimestamp.tsx +22 -0
- package/src/SimpleEmojiPicker.tsx +72 -0
- package/src/StatusTicks.tsx +39 -0
- package/src/Toast.tsx +140 -0
- package/src/Wallpaper.tsx +13 -0
- package/src/WhatsAppMessageEditor.tsx +142 -0
- package/src/channel/index.ts +1 -0
- package/src/conversations/index.ts +1 -0
- package/src/flows/FlowGroupFrame.tsx +21 -0
- package/src/flows/FlowGroupHeader.tsx +31 -0
- package/src/flows/FlowMapCanvas.tsx +76 -0
- package/src/flows/FlowMapNode.tsx +39 -0
- package/src/flows/FlowNodeCard.tsx +150 -0
- package/src/flows/FlowNodePanel.tsx +356 -0
- package/src/flows/FlowPalette.tsx +137 -0
- package/src/flows/FlowPortalNode.tsx +30 -0
- package/src/flows/FlowWhatsAppPreview.tsx +67 -0
- package/src/flows/flowGraph.ts +391 -0
- package/src/flows/index.ts +55 -0
- package/src/flows/labels.ts +187 -0
- package/src/hooks/useAsyncResource.ts +38 -0
- package/src/hooks/useConversationContext.ts +23 -0
- package/src/hooks/useConversationDocuments.ts +33 -0
- package/src/hooks/useConversationList.ts +32 -0
- package/src/hooks/useConversationMessages.ts +64 -0
- package/src/hooks/useConversationRealtime.ts +50 -0
- package/src/index.ts +87 -0
- package/src/lib/format.ts +32 -0
- package/src/lib/phone.ts +26 -0
- package/src/lib/whatsapp-formatting.tsx +215 -0
- package/src/providers/ConversationsProvider.tsx +29 -0
- package/src/providers/types.ts +54 -0
- package/src/settings/TopicsForm.tsx +109 -0
- package/src/settings/WelcomeFarewellForm.tsx +118 -0
- package/src/settings/WhatsAppCreateTemplateForm.tsx +309 -0
- package/src/settings/WhatsAppTemplateSettingsForm.tsx +264 -0
- package/src/styles.css +21 -0
- package/src/theme.ts +30 -0
- package/src/types.ts +44 -0
- package/src/useDarkMode.ts +48 -0
- package/src/useWaitingNotifications.ts +64 -0
- 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
|
+
}
|