@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,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
+ }