@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,391 @@
|
|
|
1
|
+
// Os TIPOS do grafo vêm de meta-whatsapp-contracts — é a fonte única da verdade do trio
|
|
2
|
+
// (ver rules/packages/pluggable-module.md §2). Mantê-los duplicados aqui já tinha causado drift
|
|
3
|
+
// real: o backend ganhou `FlowGraphData.version` e este pacote não, sem nada quebrar em
|
|
4
|
+
// compile-time. `import type` puro: nenhum runtime do contracts entra no bundle do frontend.
|
|
5
|
+
// O que fica local neste arquivo é só o que é de UI/layout (posicionamento, validação de
|
|
6
|
+
// publicação, limites de renderização do WhatsApp) — isso não pertence ao contrato.
|
|
7
|
+
import type {
|
|
8
|
+
FlowNodeType,
|
|
9
|
+
FlowQuestionType,
|
|
10
|
+
FlowActionKind,
|
|
11
|
+
FlowConditionOperator,
|
|
12
|
+
FlowNodeNext,
|
|
13
|
+
FlowNodeData,
|
|
14
|
+
FlowGraphData,
|
|
15
|
+
} from '@adatechnology/meta-whatsapp-contracts'
|
|
16
|
+
|
|
17
|
+
export type {
|
|
18
|
+
FlowNodeType,
|
|
19
|
+
FlowQuestionType,
|
|
20
|
+
FlowActionKind,
|
|
21
|
+
FlowConditionOperator,
|
|
22
|
+
FlowNodeNext,
|
|
23
|
+
FlowNodeData,
|
|
24
|
+
FlowGraphData,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const CONDITION_OPERATORS: FlowConditionOperator[] = ['>', '>=', '<', '<=', '==', '!=', 'contains']
|
|
28
|
+
|
|
29
|
+
// Kinds de ação genéricos que o pacote conhece de fábrica — o host pode registrar quaisquer
|
|
30
|
+
// outros via `actionKindLabels`/`actionKinds` nos componentes (ver FlowPalette, labels.ts).
|
|
31
|
+
export const BUILT_IN_ACTION_KINDS = {
|
|
32
|
+
HANDOFF: 'handoff',
|
|
33
|
+
RATE_LIMITED_HANDOFF: 'rate_limited_handoff',
|
|
34
|
+
SEND_PRODUCT_LIST: 'send_product_list',
|
|
35
|
+
} as const
|
|
36
|
+
|
|
37
|
+
// Destinos "flow:<key>" são saltos para outro fluxo, resolvidos pelo motor do host. Duplicado
|
|
38
|
+
// (em vez de importado do contracts) de propósito: são três linhas triviais e importá-las como
|
|
39
|
+
// valor puxaria o runtime do contracts para o bundle do frontend só por causa disso. O contracts
|
|
40
|
+
// exporta as mesmas funções para o backend; a convenção "flow:" é o contrato de fato.
|
|
41
|
+
export const CROSS_FLOW_PREFIX = 'flow:'
|
|
42
|
+
export const isCrossFlowTarget = (target: string): boolean => target.startsWith(CROSS_FLOW_PREFIX)
|
|
43
|
+
export const crossFlowKey = (target: string): string => target.slice(CROSS_FLOW_PREFIX.length)
|
|
44
|
+
|
|
45
|
+
// Card tem largura fixa (w-60 do Tailwind = 240px); a altura varia com o número de linhas de
|
|
46
|
+
// saída (uma por opção/condição), então o layout precisa saber isso pra não deixar a camada de
|
|
47
|
+
// baixo grudada/sobreposta num card mais alto (ex.: um menu com 7 opções é bem mais alto que
|
|
48
|
+
// uma pergunta linear de "Próximo" só).
|
|
49
|
+
export const NODE_CARD_WIDTH = 240
|
|
50
|
+
export function estimateNodeHeight(node: FlowNodeData): number {
|
|
51
|
+
const HEADER_HEIGHT = 28
|
|
52
|
+
const BODY_HEIGHT = 56
|
|
53
|
+
const PADDING = 16
|
|
54
|
+
const ROW_HEIGHT = 34
|
|
55
|
+
const rowCount =
|
|
56
|
+
node.type === 'action'
|
|
57
|
+
? 0
|
|
58
|
+
: node.type === 'condition'
|
|
59
|
+
? 2
|
|
60
|
+
: node.type === 'menu' || node.questionType === 'choice'
|
|
61
|
+
? (node.options?.length ?? 0) + 1
|
|
62
|
+
: 1
|
|
63
|
+
return HEADER_HEIGHT + BODY_HEIGHT + PADDING + rowCount * ROW_HEIGHT
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Limites reais da API do WhatsApp para mensagens interativas: até 3 opções o bot envia
|
|
67
|
+
// BOTÕES (título ≤ 20 chars); com 4+ envia LISTA (até 10 itens, título ≤ 24 chars).
|
|
68
|
+
export const WHATSAPP_LIMITS = {
|
|
69
|
+
MAX_BUTTONS: 3,
|
|
70
|
+
MAX_LIST_ROWS: 10,
|
|
71
|
+
BUTTON_TITLE_MAX: 20,
|
|
72
|
+
LIST_ROW_TITLE_MAX: 24,
|
|
73
|
+
BODY_MAX: 1024,
|
|
74
|
+
} as const
|
|
75
|
+
|
|
76
|
+
export function rendersAsButtons(options: [string, string][] | undefined): boolean {
|
|
77
|
+
return (options?.length ?? 0) <= WHATSAPP_LIMITS.MAX_BUTTONS
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function targetsOf(node: FlowNodeData): { target: string; optionId?: string; isDefault?: boolean }[] {
|
|
81
|
+
if (!node.next) return []
|
|
82
|
+
if (typeof node.next === 'string') return [{ target: node.next }]
|
|
83
|
+
return [
|
|
84
|
+
...Object.entries(node.next.byAnswer).map(([optionId, target]) => ({ target, optionId })),
|
|
85
|
+
{ target: node.next.default, isDefault: true },
|
|
86
|
+
]
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export type GraphIssue = {
|
|
90
|
+
severity: 'error' | 'warning'
|
|
91
|
+
nodeId?: string
|
|
92
|
+
message: string
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Validação de publicação: salvar já é publicar (o host lê o grafo em tempo real), então
|
|
96
|
+
// erros bloqueiam o salvamento; avisos só orientam.
|
|
97
|
+
export function validateGraph(
|
|
98
|
+
graph: FlowGraphData,
|
|
99
|
+
issueText: {
|
|
100
|
+
noStart: string
|
|
101
|
+
brokenRef: (from: string, to: string) => string
|
|
102
|
+
choiceWithoutOptions: (id: string) => string
|
|
103
|
+
duplicatedOptionId: (id: string, optionId: string) => string
|
|
104
|
+
optionWithoutTarget: (id: string, optionLabel: string) => string
|
|
105
|
+
tooManyOptions: (id: string, count: number) => string
|
|
106
|
+
buttonTitleTooLong: (id: string, label: string) => string
|
|
107
|
+
listTitleTooLong: (id: string, label: string) => string
|
|
108
|
+
bodyTooLong: (id: string) => string
|
|
109
|
+
unreachable: (id: string) => string
|
|
110
|
+
deadEndQuestion: (id: string) => string
|
|
111
|
+
conditionIncomplete: (id: string) => string
|
|
112
|
+
conditionBranchMissing: (id: string, branch: string) => string
|
|
113
|
+
},
|
|
114
|
+
): GraphIssue[] {
|
|
115
|
+
const issues: GraphIssue[] = []
|
|
116
|
+
const nodeIds = new Set(Object.keys(graph.nodes))
|
|
117
|
+
const isValidTarget = (target: string) => nodeIds.has(target) || isCrossFlowTarget(target)
|
|
118
|
+
|
|
119
|
+
if (!nodeIds.has(graph.startNodeId)) {
|
|
120
|
+
issues.push({ severity: 'error', message: issueText.noStart })
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
for (const node of Object.values(graph.nodes)) {
|
|
124
|
+
for (const { target } of targetsOf(node)) {
|
|
125
|
+
if (!isValidTarget(target)) {
|
|
126
|
+
issues.push({ severity: 'error', nodeId: node.id, message: issueText.brokenRef(node.id, target) })
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const isChoice = node.questionType === 'choice' || node.type === 'menu'
|
|
131
|
+
if (isChoice) {
|
|
132
|
+
const options = node.options ?? []
|
|
133
|
+
if (options.length === 0) {
|
|
134
|
+
issues.push({ severity: 'error', nodeId: node.id, message: issueText.choiceWithoutOptions(node.id) })
|
|
135
|
+
}
|
|
136
|
+
const seen = new Set<string>()
|
|
137
|
+
for (const [optionId, label] of options) {
|
|
138
|
+
if (seen.has(optionId)) {
|
|
139
|
+
issues.push({ severity: 'error', nodeId: node.id, message: issueText.duplicatedOptionId(node.id, optionId) })
|
|
140
|
+
}
|
|
141
|
+
seen.add(optionId)
|
|
142
|
+
const byAnswer = typeof node.next === 'object' && node.next ? node.next.byAnswer : {}
|
|
143
|
+
if (!byAnswer[optionId]) {
|
|
144
|
+
issues.push({ severity: 'warning', nodeId: node.id, message: issueText.optionWithoutTarget(node.id, label) })
|
|
145
|
+
}
|
|
146
|
+
const usesButtons = rendersAsButtons(options)
|
|
147
|
+
if (usesButtons && label.length > WHATSAPP_LIMITS.BUTTON_TITLE_MAX) {
|
|
148
|
+
issues.push({ severity: 'error', nodeId: node.id, message: issueText.buttonTitleTooLong(node.id, label) })
|
|
149
|
+
}
|
|
150
|
+
if (!usesButtons && label.length > WHATSAPP_LIMITS.LIST_ROW_TITLE_MAX) {
|
|
151
|
+
issues.push({ severity: 'error', nodeId: node.id, message: issueText.listTitleTooLong(node.id, label) })
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (options.length > WHATSAPP_LIMITS.MAX_LIST_ROWS) {
|
|
155
|
+
issues.push({ severity: 'error', nodeId: node.id, message: issueText.tooManyOptions(node.id, options.length) })
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const bodyText = node.question ?? node.directMessage ?? ''
|
|
160
|
+
if (bodyText.length > WHATSAPP_LIMITS.BODY_MAX) {
|
|
161
|
+
issues.push({ severity: 'error', nodeId: node.id, message: issueText.bodyTooLong(node.id) })
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (node.type === 'question' && !node.next) {
|
|
165
|
+
issues.push({ severity: 'warning', nodeId: node.id, message: issueText.deadEndQuestion(node.id) })
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (node.type === 'condition') {
|
|
169
|
+
if (!node.conditionContextKey || !node.conditionOperator || !node.conditionValue) {
|
|
170
|
+
issues.push({ severity: 'error', nodeId: node.id, message: issueText.conditionIncomplete(node.id) })
|
|
171
|
+
}
|
|
172
|
+
const byAnswer = typeof node.next === 'object' && node.next ? node.next.byAnswer : {}
|
|
173
|
+
if (!byAnswer.true)
|
|
174
|
+
issues.push({
|
|
175
|
+
severity: 'warning',
|
|
176
|
+
nodeId: node.id,
|
|
177
|
+
message: issueText.conditionBranchMissing(node.id, 'true'),
|
|
178
|
+
})
|
|
179
|
+
if (!byAnswer.false)
|
|
180
|
+
issues.push({
|
|
181
|
+
severity: 'warning',
|
|
182
|
+
nodeId: node.id,
|
|
183
|
+
message: issueText.conditionBranchMissing(node.id, 'false'),
|
|
184
|
+
})
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
for (const id of findUnreachable(graph)) {
|
|
189
|
+
issues.push({ severity: 'warning', nodeId: id, message: issueText.unreachable(id) })
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return issues
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function findUnreachable(graph: FlowGraphData): string[] {
|
|
196
|
+
const reachable = new Set<string>()
|
|
197
|
+
const queue = [graph.startNodeId]
|
|
198
|
+
while (queue.length > 0) {
|
|
199
|
+
const id = queue.shift()!
|
|
200
|
+
if (reachable.has(id) || !graph.nodes[id]) continue
|
|
201
|
+
reachable.add(id)
|
|
202
|
+
for (const { target } of targetsOf(graph.nodes[id])) {
|
|
203
|
+
if (!isCrossFlowTarget(target)) queue.push(target)
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return Object.keys(graph.nodes).filter((id) => !reachable.has(id))
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Auto-layout hierárquico (sem dependência externa): ranqueia por BFS a partir do início,
|
|
210
|
+
// distribui cada camada horizontalmente e centraliza. Suficiente para fluxos de conversa
|
|
211
|
+
// (grafos rasos, quase-árvores) sem puxar uma lib de layout inteira.
|
|
212
|
+
export function computeAutoLayout(graph: FlowGraphData): Record<string, { x: number; y: number }> {
|
|
213
|
+
const H_GAP = 300
|
|
214
|
+
// Espaço extra entre a camada mais alta de uma linha e a próxima linha, além da altura real
|
|
215
|
+
// do card mais alto dela — sem isso, um menu com várias opções (card bem mais alto) encostaria
|
|
216
|
+
// na camada de baixo mesmo com um gap fixo pensado pra cards curtos.
|
|
217
|
+
const V_GAP = 90
|
|
218
|
+
const rank: Record<string, number> = {}
|
|
219
|
+
const queue: string[] = [graph.startNodeId]
|
|
220
|
+
rank[graph.startNodeId] = 0
|
|
221
|
+
|
|
222
|
+
while (queue.length > 0) {
|
|
223
|
+
const id = queue.shift()!
|
|
224
|
+
const node = graph.nodes[id]
|
|
225
|
+
if (!node) continue
|
|
226
|
+
for (const { target } of targetsOf(node)) {
|
|
227
|
+
if (isCrossFlowTarget(target) || !graph.nodes[target]) continue
|
|
228
|
+
if (rank[target] === undefined) {
|
|
229
|
+
rank[target] = rank[id]! + 1
|
|
230
|
+
queue.push(target)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Nós não alcançáveis vão para uma camada abaixo da última, na ordem em que aparecem.
|
|
236
|
+
const maxRank = Math.max(0, ...Object.values(rank))
|
|
237
|
+
let strayRank = maxRank + 1
|
|
238
|
+
for (const id of Object.keys(graph.nodes)) {
|
|
239
|
+
if (rank[id] === undefined) rank[id] = strayRank++
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const layers: Record<number, string[]> = {}
|
|
243
|
+
for (const [id, r] of Object.entries(rank)) {
|
|
244
|
+
layers[r] = [...(layers[r] ?? []), id]
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const positions: Record<string, { x: number; y: number }> = {}
|
|
248
|
+
const sortedRanks = Object.keys(layers)
|
|
249
|
+
.map(Number)
|
|
250
|
+
.sort((a, b) => a - b)
|
|
251
|
+
let cumulativeY = 0
|
|
252
|
+
for (const r of sortedRanks) {
|
|
253
|
+
const ids = layers[r]!
|
|
254
|
+
const width = (ids.length - 1) * H_GAP
|
|
255
|
+
let maxHeight = 0
|
|
256
|
+
ids.forEach((id, index) => {
|
|
257
|
+
maxHeight = Math.max(maxHeight, estimateNodeHeight(graph.nodes[id]!))
|
|
258
|
+
positions[id] = { x: index * H_GAP - width / 2, y: cumulativeY }
|
|
259
|
+
})
|
|
260
|
+
cumulativeY += maxHeight + V_GAP
|
|
261
|
+
}
|
|
262
|
+
return positions
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Chaves de fluxo (sem duplicatas) que este fluxo referencia via "flow:<key>" — usado tanto pro
|
|
266
|
+
// mapa de fluxos (visão hierárquica) quanto pra decidir, na fusão editável, se um salto já
|
|
267
|
+
// mesclado no canvas deve virar ligação real ou continuar como portal.
|
|
268
|
+
export function crossFlowTargetsOf(graph: FlowGraphData): string[] {
|
|
269
|
+
const keys = new Set<string>()
|
|
270
|
+
for (const node of Object.values(graph.nodes)) {
|
|
271
|
+
for (const { target } of targetsOf(node)) {
|
|
272
|
+
if (isCrossFlowTarget(target)) keys.add(crossFlowKey(target))
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return [...keys]
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Auto-layout do MAPA de fluxos: mesma ideia do computeAutoLayout, mas em granularidade de
|
|
279
|
+
// fluxo inteiro (cada fluxo é "um nó"), ranqueado por BFS a partir do fluxo raiz (normalmente
|
|
280
|
+
// o menu principal) usando crossFlowTargetsOf como as arestas.
|
|
281
|
+
export function computeFlowMapLayout(
|
|
282
|
+
graphs: Record<string, FlowGraphData>,
|
|
283
|
+
rootKey: string,
|
|
284
|
+
): Record<string, { x: number; y: number }> {
|
|
285
|
+
const H_GAP = 280
|
|
286
|
+
const V_GAP = 170
|
|
287
|
+
const rank: Record<string, number> = {}
|
|
288
|
+
const queue: string[] = graphs[rootKey] ? [rootKey] : Object.keys(graphs)
|
|
289
|
+
if (graphs[rootKey]) rank[rootKey] = 0
|
|
290
|
+
|
|
291
|
+
while (queue.length > 0) {
|
|
292
|
+
const key = queue.shift()!
|
|
293
|
+
const g = graphs[key]
|
|
294
|
+
if (!g) continue
|
|
295
|
+
for (const target of crossFlowTargetsOf(g)) {
|
|
296
|
+
if (!graphs[target]) continue
|
|
297
|
+
if (rank[target] === undefined) {
|
|
298
|
+
rank[target] = rank[key]! + 1
|
|
299
|
+
queue.push(target)
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const maxRank = Math.max(0, ...Object.values(rank))
|
|
305
|
+
let strayRank = maxRank + 1
|
|
306
|
+
for (const key of Object.keys(graphs)) {
|
|
307
|
+
if (rank[key] === undefined) rank[key] = strayRank++
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const layers: Record<number, string[]> = {}
|
|
311
|
+
for (const [key, r] of Object.entries(rank)) {
|
|
312
|
+
layers[r] = [...(layers[r] ?? []), key]
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const positions: Record<string, { x: number; y: number }> = {}
|
|
316
|
+
for (const [r, keys] of Object.entries(layers)) {
|
|
317
|
+
const width = (keys.length - 1) * H_GAP
|
|
318
|
+
keys.forEach((key, index) => {
|
|
319
|
+
positions[key] = { x: index * H_GAP - width / 2, y: Number(r) * V_GAP }
|
|
320
|
+
})
|
|
321
|
+
}
|
|
322
|
+
return positions
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export type CollectionChain = { nodeIds: string[]; actionNodeId: string }
|
|
326
|
+
|
|
327
|
+
// Detecta o conjunto de perguntas que alimentam EXCLUSIVAMENTE um nó de ação — inclui
|
|
328
|
+
// perguntas de escolha (ex.: "Possui mais de 3 anos de FGTS? Sim/Não") desde que TODOS os
|
|
329
|
+
// ramos dela convirjam pra essa mesma ação, não só perguntas lineares numa fila reta. Puramente
|
|
330
|
+
// derivado da topologia do grafo (não é um dado novo persistido) — usado só pra desenhar uma
|
|
331
|
+
// moldura visual ("essas N perguntas alimentam essa ação").
|
|
332
|
+
export function findCollectionChains(graph: FlowGraphData): CollectionChain[] {
|
|
333
|
+
const actionIds = new Set(
|
|
334
|
+
Object.values(graph.nodes)
|
|
335
|
+
.filter((n) => n.type === 'action')
|
|
336
|
+
.map((n) => n.id),
|
|
337
|
+
)
|
|
338
|
+
const memo = new Map<string, Set<string>>()
|
|
339
|
+
|
|
340
|
+
// Quais ações (nenhuma, uma ou várias) são alcançáveis a partir deste nó, seguindo só
|
|
341
|
+
// ligações dentro do próprio fluxo (saltos flow:<key> não contam — pertencem a outro fluxo).
|
|
342
|
+
function reachableActions(id: string, stack: Set<string>): Set<string> {
|
|
343
|
+
if (memo.has(id)) return memo.get(id)!
|
|
344
|
+
if (stack.has(id)) return new Set()
|
|
345
|
+
if (actionIds.has(id)) return new Set([id])
|
|
346
|
+
const node = graph.nodes[id]
|
|
347
|
+
if (!node) return new Set()
|
|
348
|
+
|
|
349
|
+
const nextStack = new Set(stack)
|
|
350
|
+
nextStack.add(id)
|
|
351
|
+
const result = new Set<string>()
|
|
352
|
+
for (const { target } of targetsOf(node)) {
|
|
353
|
+
if (isCrossFlowTarget(target) || !graph.nodes[target]) continue
|
|
354
|
+
for (const actionId of reachableActions(target, nextStack)) result.add(actionId)
|
|
355
|
+
}
|
|
356
|
+
memo.set(id, result)
|
|
357
|
+
return result
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const nodeIdsByAction = new Map<string, string[]>()
|
|
361
|
+
for (const node of Object.values(graph.nodes)) {
|
|
362
|
+
if (node.type !== 'question') continue
|
|
363
|
+
const reached = reachableActions(node.id, new Set())
|
|
364
|
+
if (reached.size !== 1) continue
|
|
365
|
+
const [actionNodeId] = [...reached]
|
|
366
|
+
nodeIdsByAction.set(actionNodeId!, [...(nodeIdsByAction.get(actionNodeId!) ?? []), node.id])
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return [...nodeIdsByAction.entries()]
|
|
370
|
+
.filter(([, nodeIds]) => nodeIds.length >= 2)
|
|
371
|
+
.map(([actionNodeId, nodeIds]) => ({ actionNodeId, nodeIds }))
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Gera um id de nó único e legível a partir do rótulo (ex.: "Qual sua renda?" → "qual_sua_renda").
|
|
375
|
+
export function slugifyNodeId(label: string, existing: Set<string>): string {
|
|
376
|
+
const base =
|
|
377
|
+
label
|
|
378
|
+
.toLowerCase()
|
|
379
|
+
.normalize('NFD')
|
|
380
|
+
.replace(/[̀-ͯ]/g, '')
|
|
381
|
+
.replace(/[^a-z0-9]+/g, '_')
|
|
382
|
+
.replace(/^_+|_+$/g, '')
|
|
383
|
+
.slice(0, 30) || 'no'
|
|
384
|
+
let candidate = base
|
|
385
|
+
let counter = 2
|
|
386
|
+
while (existing.has(candidate)) {
|
|
387
|
+
candidate = `${base}_${counter}`
|
|
388
|
+
counter++
|
|
389
|
+
}
|
|
390
|
+
return candidate
|
|
391
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Editor de fluxograma (T7.2) — subpath dedicado para que importar
|
|
2
|
+
// '@adatechnology/conversations-ui' sem '/flows' não puxe @xyflow/react ao bundle do host.
|
|
3
|
+
export { FlowNodeCard, flowNodeTypes, nodeLabel } from './FlowNodeCard'
|
|
4
|
+
export { FlowMapNode, flowMapNodeTypes } from './FlowMapNode'
|
|
5
|
+
export { FlowMapCanvas } from './FlowMapCanvas'
|
|
6
|
+
export { FlowGroupFrame, flowGroupFrameNodeTypes } from './FlowGroupFrame'
|
|
7
|
+
export { FlowGroupHeader, flowGroupHeaderNodeTypes } from './FlowGroupHeader'
|
|
8
|
+
export { FlowPortalNode, flowPortalNodeTypes } from './FlowPortalNode'
|
|
9
|
+
export { FlowPalette } from './FlowPalette'
|
|
10
|
+
export { FlowNodePanel } from './FlowNodePanel'
|
|
11
|
+
export { FlowWhatsAppPreview } from './FlowWhatsAppPreview'
|
|
12
|
+
|
|
13
|
+
export { DEFAULT_FLOW_EDITOR_LABELS, mergeFlowEditorLabels } from './labels'
|
|
14
|
+
|
|
15
|
+
export {
|
|
16
|
+
CONDITION_OPERATORS,
|
|
17
|
+
BUILT_IN_ACTION_KINDS,
|
|
18
|
+
NODE_CARD_WIDTH,
|
|
19
|
+
WHATSAPP_LIMITS,
|
|
20
|
+
CROSS_FLOW_PREFIX,
|
|
21
|
+
isCrossFlowTarget,
|
|
22
|
+
crossFlowKey,
|
|
23
|
+
estimateNodeHeight,
|
|
24
|
+
rendersAsButtons,
|
|
25
|
+
targetsOf,
|
|
26
|
+
validateGraph,
|
|
27
|
+
computeAutoLayout,
|
|
28
|
+
crossFlowTargetsOf,
|
|
29
|
+
computeFlowMapLayout,
|
|
30
|
+
findCollectionChains,
|
|
31
|
+
slugifyNodeId,
|
|
32
|
+
} from './flowGraph'
|
|
33
|
+
|
|
34
|
+
export type {
|
|
35
|
+
FlowNodeType,
|
|
36
|
+
FlowQuestionType,
|
|
37
|
+
FlowActionKind,
|
|
38
|
+
FlowConditionOperator,
|
|
39
|
+
FlowNodeNext,
|
|
40
|
+
FlowNodeData,
|
|
41
|
+
FlowGraphData,
|
|
42
|
+
GraphIssue,
|
|
43
|
+
CollectionChain,
|
|
44
|
+
} from './flowGraph'
|
|
45
|
+
|
|
46
|
+
export type { FlowEditorLabels } from './labels'
|
|
47
|
+
export type { FlowNodeCardData } from './FlowNodeCard'
|
|
48
|
+
export type { FlowMapNodeData } from './FlowMapNode'
|
|
49
|
+
export type { FlowMapCanvasProps } from './FlowMapCanvas'
|
|
50
|
+
export type { FlowGroupFrameData } from './FlowGroupFrame'
|
|
51
|
+
export type { FlowGroupHeaderData } from './FlowGroupHeader'
|
|
52
|
+
export type { FlowPortalNodeData } from './FlowPortalNode'
|
|
53
|
+
export type { FlowPaletteProps, FlowPaletteActionOption, NewNodeSpec } from './FlowPalette'
|
|
54
|
+
export type { FlowNodePanelProps } from './FlowNodePanel'
|
|
55
|
+
export type { FlowWhatsAppPreviewProps } from './FlowWhatsAppPreview'
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import type { FlowConditionOperator, FlowNodeType, FlowQuestionType } from './flowGraph'
|
|
2
|
+
|
|
3
|
+
export interface FlowEditorLabels {
|
|
4
|
+
legend: Record<FlowNodeType, string>
|
|
5
|
+
startNodeTooltip: string
|
|
6
|
+
liveCountTooltip: (count: number) => string
|
|
7
|
+
edgeFallbackLabel: string
|
|
8
|
+
// Rótulo por `actionKind` — o host estende esse mapa para registrar seus próprios kinds
|
|
9
|
+
// (ex.: 'trigger_simulation') em vez do pacote assumir algum como padrão.
|
|
10
|
+
actionKindLabels: Record<string, string>
|
|
11
|
+
conditionOperatorLabels: Record<FlowConditionOperator, string>
|
|
12
|
+
questionTypeLabels: Record<FlowQuestionType, string>
|
|
13
|
+
nodePanel: {
|
|
14
|
+
title: string
|
|
15
|
+
contextKey: string
|
|
16
|
+
questionType: string
|
|
17
|
+
question: string
|
|
18
|
+
options: string
|
|
19
|
+
addOption: string
|
|
20
|
+
optionId: string
|
|
21
|
+
optionLabel: string
|
|
22
|
+
next: string
|
|
23
|
+
nextHint: string
|
|
24
|
+
nextRowLabel: string
|
|
25
|
+
otherFlowsGroup: string
|
|
26
|
+
nextByAnswer: (id: string) => string
|
|
27
|
+
nextDefault: string
|
|
28
|
+
save: string
|
|
29
|
+
cancel: string
|
|
30
|
+
fixedLogicNotice: string
|
|
31
|
+
actionNotice: string
|
|
32
|
+
preview: string
|
|
33
|
+
previewPlaceholder: string
|
|
34
|
+
previewEmptyBody: string
|
|
35
|
+
previewEmptyOption: string
|
|
36
|
+
previewListButton: string
|
|
37
|
+
previewModeButtons: string
|
|
38
|
+
previewModeList: string
|
|
39
|
+
delete: string
|
|
40
|
+
deleteConfirm: string
|
|
41
|
+
directMessage: string
|
|
42
|
+
fallbackMessage: string
|
|
43
|
+
conditionNotice: string
|
|
44
|
+
conditionVariable: string
|
|
45
|
+
conditionOperator: string
|
|
46
|
+
conditionValue: string
|
|
47
|
+
conditionTrue: string
|
|
48
|
+
conditionFalse: string
|
|
49
|
+
conditionVariableMissing: string
|
|
50
|
+
}
|
|
51
|
+
palette: {
|
|
52
|
+
title: string
|
|
53
|
+
question: string
|
|
54
|
+
decision: string
|
|
55
|
+
condition: string
|
|
56
|
+
conditionHint: string
|
|
57
|
+
action: string
|
|
58
|
+
}
|
|
59
|
+
flowMap: {
|
|
60
|
+
nodeCount: (count: number) => string
|
|
61
|
+
openFlow: string
|
|
62
|
+
}
|
|
63
|
+
flowGroup: {
|
|
64
|
+
focus: string
|
|
65
|
+
close: string
|
|
66
|
+
}
|
|
67
|
+
crossFlowPortal: {
|
|
68
|
+
tooltip: string
|
|
69
|
+
goesTo: (label: string) => string
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Paridade de texto com financiamento-imobiliario-bot/apps/web/src/locales/modules/flows.ts —
|
|
74
|
+
// mesmo padrão de `labels` (partial override sobre defaults pt-BR) já usado em ./settings/*.
|
|
75
|
+
export const DEFAULT_FLOW_EDITOR_LABELS: FlowEditorLabels = {
|
|
76
|
+
legend: {
|
|
77
|
+
question: 'Pergunta',
|
|
78
|
+
entrada_choice: 'Escolha calculada',
|
|
79
|
+
action: 'Ação (simulação/atendimento)',
|
|
80
|
+
menu: 'Menu',
|
|
81
|
+
condition: 'Condição',
|
|
82
|
+
},
|
|
83
|
+
startNodeTooltip: 'Início do fluxo',
|
|
84
|
+
liveCountTooltip: (count) => `${count} conversa(s) ativa(s) aqui agora`,
|
|
85
|
+
edgeFallbackLabel: 'outro',
|
|
86
|
+
actionKindLabels: {
|
|
87
|
+
handoff: 'Encaminhar para atendimento',
|
|
88
|
+
rate_limited_handoff: 'Encaminhar (limite de simulações atingido)',
|
|
89
|
+
send_product_list: 'Enviar catálogo de produtos',
|
|
90
|
+
},
|
|
91
|
+
conditionOperatorLabels: {
|
|
92
|
+
'>': 'maior que',
|
|
93
|
+
'>=': 'maior ou igual a',
|
|
94
|
+
'<': 'menor que',
|
|
95
|
+
'<=': 'menor ou igual a',
|
|
96
|
+
'==': 'igual a',
|
|
97
|
+
'!=': 'diferente de',
|
|
98
|
+
contains: 'contém',
|
|
99
|
+
},
|
|
100
|
+
questionTypeLabels: {
|
|
101
|
+
text: 'Texto livre',
|
|
102
|
+
money: 'Valor em R$',
|
|
103
|
+
date: 'Data',
|
|
104
|
+
int: 'Número inteiro',
|
|
105
|
+
cpf: 'CPF',
|
|
106
|
+
choice: 'Escolha (botões/lista)',
|
|
107
|
+
},
|
|
108
|
+
nodePanel: {
|
|
109
|
+
title: 'Editar nó',
|
|
110
|
+
contextKey: 'Chave (contexto)',
|
|
111
|
+
questionType: 'Tipo de resposta',
|
|
112
|
+
question: 'Texto da pergunta',
|
|
113
|
+
options: 'Opções (choice)',
|
|
114
|
+
addOption: 'Adicionar opção',
|
|
115
|
+
optionId: 'Valor',
|
|
116
|
+
optionLabel: 'Texto exibido',
|
|
117
|
+
next: 'Próximo nó',
|
|
118
|
+
nextHint: 'Também é possível arrastar um fio no canvas para conectar.',
|
|
119
|
+
nextRowLabel: 'Próximo',
|
|
120
|
+
otherFlowsGroup: 'Outros fluxos (salto)',
|
|
121
|
+
nextByAnswer: (id) => `Se responder "${id}" →`,
|
|
122
|
+
nextDefault: 'Caso contrário →',
|
|
123
|
+
save: 'Salvar alterações',
|
|
124
|
+
cancel: 'Cancelar',
|
|
125
|
+
fixedLogicNotice:
|
|
126
|
+
'Este nó tem lógica fixa (validações/cálculos do sistema) — só o texto e o destino são editáveis.',
|
|
127
|
+
actionNotice: 'Nó de ação — dispara a ação registrada ou encaminha para atendimento humano.',
|
|
128
|
+
preview: 'Como o cliente vê no WhatsApp',
|
|
129
|
+
previewPlaceholder: 'Pré-visualização…',
|
|
130
|
+
previewEmptyBody: '(escreva o texto da mensagem)',
|
|
131
|
+
previewEmptyOption: '(sem texto)',
|
|
132
|
+
previewListButton: 'Ver opções',
|
|
133
|
+
previewModeButtons: 'Enviado como botões (até 3 opções)',
|
|
134
|
+
previewModeList: 'Enviado como lista (4+ opções)',
|
|
135
|
+
delete: 'Excluir nó',
|
|
136
|
+
deleteConfirm: 'Excluir este nó? Ligações que apontam para ele ficarão quebradas.',
|
|
137
|
+
directMessage: 'Mensagem (opcional)',
|
|
138
|
+
fallbackMessage: 'Mensagem de fallback (quando o catálogo não está disponível)',
|
|
139
|
+
conditionNotice: 'Nó de condição — não pergunta nada ao cliente, só decide automaticamente entre Verdadeiro/Falso.',
|
|
140
|
+
conditionVariable: 'Variável (chave já coletada por uma pergunta anterior)',
|
|
141
|
+
conditionOperator: 'Operador',
|
|
142
|
+
conditionValue: 'Valor de comparação',
|
|
143
|
+
conditionTrue: 'Se verdadeiro →',
|
|
144
|
+
conditionFalse: 'Se falso →',
|
|
145
|
+
conditionVariableMissing: 'Se a variável ainda não foi coletada →',
|
|
146
|
+
},
|
|
147
|
+
palette: {
|
|
148
|
+
title: 'Adicionar ao fluxo',
|
|
149
|
+
question: 'Pergunta',
|
|
150
|
+
decision: 'Decisão',
|
|
151
|
+
condition: 'Condição',
|
|
152
|
+
conditionHint: 'Compara uma variável já coletada e segue automaticamente, sem perguntar nada',
|
|
153
|
+
action: 'Ação',
|
|
154
|
+
},
|
|
155
|
+
flowMap: {
|
|
156
|
+
nodeCount: (count) => `${count} nó(s)`,
|
|
157
|
+
openFlow: 'Abrir fluxo',
|
|
158
|
+
},
|
|
159
|
+
flowGroup: {
|
|
160
|
+
focus: 'Focar neste fluxo',
|
|
161
|
+
close: 'Fechar',
|
|
162
|
+
},
|
|
163
|
+
crossFlowPortal: {
|
|
164
|
+
tooltip: 'Clique para abrir esse fluxo aqui do lado, ligado ao ponto de onde ele é chamado',
|
|
165
|
+
goesTo: (label) => `↪ Vai para: ${label}`,
|
|
166
|
+
},
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function mergeFlowEditorLabels(override?: Partial<FlowEditorLabels>): FlowEditorLabels {
|
|
170
|
+
if (!override) return DEFAULT_FLOW_EDITOR_LABELS
|
|
171
|
+
return {
|
|
172
|
+
...DEFAULT_FLOW_EDITOR_LABELS,
|
|
173
|
+
...override,
|
|
174
|
+
legend: { ...DEFAULT_FLOW_EDITOR_LABELS.legend, ...override.legend },
|
|
175
|
+
actionKindLabels: { ...DEFAULT_FLOW_EDITOR_LABELS.actionKindLabels, ...override.actionKindLabels },
|
|
176
|
+
conditionOperatorLabels: {
|
|
177
|
+
...DEFAULT_FLOW_EDITOR_LABELS.conditionOperatorLabels,
|
|
178
|
+
...override.conditionOperatorLabels,
|
|
179
|
+
},
|
|
180
|
+
questionTypeLabels: { ...DEFAULT_FLOW_EDITOR_LABELS.questionTypeLabels, ...override.questionTypeLabels },
|
|
181
|
+
nodePanel: { ...DEFAULT_FLOW_EDITOR_LABELS.nodePanel, ...override.nodePanel },
|
|
182
|
+
palette: { ...DEFAULT_FLOW_EDITOR_LABELS.palette, ...override.palette },
|
|
183
|
+
flowMap: { ...DEFAULT_FLOW_EDITOR_LABELS.flowMap, ...override.flowMap },
|
|
184
|
+
flowGroup: { ...DEFAULT_FLOW_EDITOR_LABELS.flowGroup, ...override.flowGroup },
|
|
185
|
+
crossFlowPortal: { ...DEFAULT_FLOW_EDITOR_LABELS.crossFlowPortal, ...override.crossFlowPortal },
|
|
186
|
+
}
|
|
187
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
export interface AsyncResourceState<T> {
|
|
4
|
+
data: T | undefined
|
|
5
|
+
loading: boolean
|
|
6
|
+
error: Error | undefined
|
|
7
|
+
refetch: () => Promise<void>
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Fundação interna da camada headless — busca uma vez por mudança de `deps`, expõe
|
|
11
|
+
// `refetch` para revalidação manual, e ignora respostas de requisições obsoletas
|
|
12
|
+
// (evita "race condition" clássica quando conversationId muda rápido).
|
|
13
|
+
export function useAsyncResource<T>(fetcher: () => Promise<T>, deps: unknown[]): AsyncResourceState<T> {
|
|
14
|
+
const [data, setData] = useState<T | undefined>(undefined)
|
|
15
|
+
const [loading, setLoading] = useState(false)
|
|
16
|
+
const [error, setError] = useState<Error | undefined>(undefined)
|
|
17
|
+
const requestIdRef = useRef(0)
|
|
18
|
+
|
|
19
|
+
const load = useCallback(async () => {
|
|
20
|
+
const requestId = ++requestIdRef.current
|
|
21
|
+
setLoading(true)
|
|
22
|
+
setError(undefined)
|
|
23
|
+
try {
|
|
24
|
+
const result = await fetcher()
|
|
25
|
+
if (requestId === requestIdRef.current) setData(result)
|
|
26
|
+
} catch (err) {
|
|
27
|
+
if (requestId === requestIdRef.current) setError(err instanceof Error ? err : new Error(String(err)))
|
|
28
|
+
} finally {
|
|
29
|
+
if (requestId === requestIdRef.current) setLoading(false)
|
|
30
|
+
}
|
|
31
|
+
}, deps)
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
load()
|
|
35
|
+
}, [load])
|
|
36
|
+
|
|
37
|
+
return { data, loading, error, refetch: load }
|
|
38
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { useConversations } from '../providers/ConversationsProvider'
|
|
2
|
+
import { useAsyncResource } from './useAsyncResource'
|
|
3
|
+
|
|
4
|
+
export interface UseConversationContextResult {
|
|
5
|
+
context: Record<string, unknown> | undefined
|
|
6
|
+
loading: boolean
|
|
7
|
+
error: Error | undefined
|
|
8
|
+
refetch: () => Promise<void>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Dados de contexto da conversa (variáveis coletadas no fluxo) — o produto decide como
|
|
12
|
+
// exibir (ex: <SelectionsSummary> no bot, ou uma UI própria).
|
|
13
|
+
export function useConversationContext(conversationId: string): UseConversationContextResult {
|
|
14
|
+
const conversationsContext = useConversations()
|
|
15
|
+
if (!conversationsContext) {
|
|
16
|
+
throw new Error('useConversationContext requires an ancestor <ConversationsProvider>')
|
|
17
|
+
}
|
|
18
|
+
const { api } = conversationsContext
|
|
19
|
+
|
|
20
|
+
const { data, loading, error, refetch } = useAsyncResource(() => api.getContext(conversationId), [conversationId])
|
|
21
|
+
|
|
22
|
+
return { context: data, loading, error, refetch }
|
|
23
|
+
}
|