@adatechnology/conversations-ui 0.1.0-rc.9 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/dist/ConversationSimulatorPanel--5fIzXWY.d.ts +804 -0
  2. package/dist/{chunk-G5BM3VBP.js → chunk-BJNRLLDO.js} +1248 -282
  3. package/dist/chunk-DKPXKQGC.js +110 -0
  4. package/dist/{chunk-2AYDBWNE.js → chunk-WCBDXZ3X.js} +13 -3
  5. package/dist/flows/index.d.ts +422 -5
  6. package/dist/flows/index.js +2502 -676
  7. package/dist/index.d.ts +919 -17
  8. package/dist/index.js +3676 -675
  9. package/dist/preview/index.d.ts +62 -105
  10. package/dist/preview/index.js +162 -284
  11. package/dist/styles.css +893 -0
  12. package/package.json +9 -8
  13. package/src/AudioPlayer.tsx +8 -0
  14. package/src/AudioRecorderButton.test.tsx +30 -0
  15. package/src/AudioRecorderButton.tsx +248 -0
  16. package/src/AudioTranscription.test.tsx +115 -0
  17. package/src/AudioTranscription.tsx +252 -0
  18. package/src/Avatar.tsx +1 -1
  19. package/src/ConversationContextPanel.tsx +218 -44
  20. package/src/ConversationDocumentsPanel.tsx +11 -6
  21. package/src/ConversationHeader.test.tsx +66 -0
  22. package/src/ConversationHeader.tsx +147 -47
  23. package/src/ConversationListItem.tsx +8 -6
  24. package/src/ConversationLocalesProvider.tsx +28 -0
  25. package/src/ConversationRow.tsx +53 -7
  26. package/src/DarkModeToggle.test.tsx +76 -0
  27. package/src/DarkModeToggle.tsx +92 -0
  28. package/src/DocumentsLibrary.tsx +67 -7
  29. package/src/EmojiPicker.tsx +2 -1
  30. package/src/InteractiveMessage.tsx +3 -0
  31. package/src/Lightbox.tsx +1 -1
  32. package/src/MediaRenderer.tsx +88 -15
  33. package/src/MessageBubble.test.tsx +41 -0
  34. package/src/MessageBubble.tsx +47 -5
  35. package/src/MessageComposer.test.tsx +35 -0
  36. package/src/MessageComposer.tsx +122 -17
  37. package/src/MessageText.tsx +2 -1
  38. package/src/MessageTimestamp.tsx +2 -1
  39. package/src/RichMessageComposer.test.tsx +113 -0
  40. package/src/RichMessageComposer.tsx +551 -0
  41. package/src/SimpleEmojiPicker.tsx +5 -3
  42. package/src/StatusTicks.tsx +1 -1
  43. package/src/Toast.tsx +4 -0
  44. package/src/Tooltip.test.ts +42 -0
  45. package/src/Tooltip.tsx +167 -0
  46. package/src/Wallpaper.tsx +27 -13
  47. package/src/WhatsAppMessageEditor.tsx +10 -7
  48. package/src/WindowExpiredNotice.tsx +12 -4
  49. package/src/{preview/audioRecorderFormat.test.ts → audioRecorderFormat.test.ts} +1 -1
  50. package/src/buildOutput.test.ts +79 -0
  51. package/src/composer.constant.ts +33 -0
  52. package/src/conversationTranscript.test.ts +57 -0
  53. package/src/conversationTranscript.ts +29 -4
  54. package/src/conversationWindow.ts +7 -5
  55. package/src/documentTypeLabel.test.ts +57 -0
  56. package/src/documents/DocumentsWorkspace.tsx +550 -0
  57. package/src/documents/index.ts +8 -0
  58. package/src/documents/labels.ts +92 -0
  59. package/src/flows/FlowConnectionEdge.tsx +104 -0
  60. package/src/flows/FlowGroupHeader.tsx +12 -2
  61. package/src/flows/FlowLegend.tsx +125 -0
  62. package/src/flows/FlowMapCanvas.tsx +15 -12
  63. package/src/flows/FlowMapNode.tsx +4 -1
  64. package/src/flows/FlowNodeCard.tsx +219 -34
  65. package/src/flows/FlowNodePanel.tsx +153 -39
  66. package/src/flows/FlowPalette.tsx +156 -70
  67. package/src/flows/FlowPortalNode.tsx +1 -1
  68. package/src/flows/FlowWhatsAppPreview.tsx +14 -3
  69. package/src/flows/FlowsWorkspace.tsx +1255 -0
  70. package/src/flows/flowCanvasModel.test.ts +456 -0
  71. package/src/flows/flowCanvasModel.ts +378 -0
  72. package/src/flows/flowEditorOps.test.ts +276 -0
  73. package/src/flows/flowEditorOps.ts +202 -0
  74. package/src/flows/flowGraph.ts +78 -53
  75. package/src/flows/flowMenuPlacement.test.ts +130 -0
  76. package/src/flows/flowMenuPlacement.ts +86 -0
  77. package/src/flows/index.ts +51 -2
  78. package/src/flows/labels.ts +180 -0
  79. package/src/flows/workspaceContract.test.ts +126 -0
  80. package/src/hooks/useContainerWidth.ts +35 -0
  81. package/src/hooks/useConversationRealtime.ts +10 -8
  82. package/src/hooks/useScrollToLatestMessage.ts +127 -0
  83. package/src/hooks/useUrlFilterState.ts +107 -0
  84. package/src/icon.constant.ts +12 -0
  85. package/src/index.ts +100 -0
  86. package/src/lib/composer-formatting.test.ts +78 -0
  87. package/src/lib/composer-formatting.ts +145 -0
  88. package/src/lib/whatsapp-formatting.test.tsx +37 -0
  89. package/src/lib/whatsapp-formatting.tsx +28 -3
  90. package/src/listing/index.tsx +202 -0
  91. package/src/pagination.constant.ts +10 -0
  92. package/src/preview/ConversationPreview.tsx +84 -45
  93. package/src/preview/ConversationSimulatorClient.ts +143 -0
  94. package/src/preview/ConversationSimulatorPanel.test.tsx +55 -0
  95. package/src/preview/ConversationSimulatorPanel.tsx +131 -0
  96. package/src/preview/createPreviewBridgeClient.test.ts +92 -0
  97. package/src/preview/createPreviewBridgeClient.ts +124 -0
  98. package/src/preview/createPreviewMediaUploader.ts +82 -0
  99. package/src/preview/createPreviewWebhookClient.test.ts +96 -0
  100. package/src/preview/createPreviewWebhookClient.ts +99 -3
  101. package/src/preview/index.ts +36 -2
  102. package/src/preview/previewMediaUploader.test.ts +61 -0
  103. package/src/providers/ConversationsProvider.tsx +8 -6
  104. package/src/providers/types.ts +59 -2
  105. package/src/quickReply.test.ts +58 -0
  106. package/src/replyLatency.test.ts +71 -0
  107. package/src/replyLatency.ts +57 -0
  108. package/src/settings/MessagesWorkspace.tsx +571 -0
  109. package/src/settings/TopicsForm.tsx +2 -0
  110. package/src/settings/TranscriptionSettingsForm.test.tsx +81 -0
  111. package/src/settings/TranscriptionSettingsForm.tsx +190 -0
  112. package/src/settings/WelcomeFarewellForm.tsx +1 -0
  113. package/src/settings/WhatsAppCreateTemplateForm.tsx +1 -0
  114. package/src/settings/WhatsAppTemplateSettingsForm.tsx +5 -2
  115. package/src/settings/WhatsAppTemplatesSettings.test.tsx +61 -0
  116. package/src/settings/WhatsAppTemplatesSettings.tsx +22 -2
  117. package/src/styles.css +858 -0
  118. package/src/theme.ts +13 -0
  119. package/src/types.ts +26 -0
  120. package/src/workspace/BulkTemplateModal.tsx +132 -0
  121. package/src/workspace/ConversationPane.tsx +432 -0
  122. package/src/workspace/ConversationsInboxList.tsx +194 -0
  123. package/src/workspace/ConversationsWorkspace.tsx +423 -0
  124. package/src/workspace/index.ts +17 -0
  125. package/src/workspace/labels.test.ts +17 -0
  126. package/src/workspace/labels.ts +85 -0
  127. package/src/workspace/useConversationsInbox.ts +332 -0
  128. package/dist/types-B5C1DLu1.d.ts +0 -365
  129. package/src/preview/AudioRecorderButton.tsx +0 -117
@@ -0,0 +1,1255 @@
1
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react'
2
+ import {
3
+ ReactFlow,
4
+ Background,
5
+ Controls,
6
+ MiniMap,
7
+ MarkerType,
8
+ Panel,
9
+ applyNodeChanges,
10
+ type Node,
11
+ type NodeChange,
12
+ type Edge,
13
+ type Connection,
14
+ type ReactFlowInstance,
15
+ } from '@xyflow/react'
16
+ import '@xyflow/react/dist/style.css'
17
+ import { Plus, Trash2, LayoutGrid, AlertTriangle, AlertCircle, Save, Undo2, Map as MapIcon, Workflow } from 'lucide-react'
18
+
19
+ import { useIsDarkTheme } from '../useDarkMode'
20
+ import { NODE_TYPE_COLOR, flowNodeTypes, nodeLabel, type FlowNodeCardData } from './FlowNodeCard'
21
+ import { FlowLegend, type FlowLegendEdgeSample } from './FlowLegend'
22
+ import { flowPortalNodeTypes, type FlowPortalNodeData } from './FlowPortalNode'
23
+ import { flowGroupHeaderNodeTypes, type FlowGroupHeaderData } from './FlowGroupHeader'
24
+ import { flowGroupFrameNodeTypes, type FlowGroupFrameData } from './FlowGroupFrame'
25
+ import { FlowNodePanel } from './FlowNodePanel'
26
+ import { FlowPalette, FlowPaletteMenu, type FlowPaletteActionOption, type NewNodeSpec } from './FlowPalette'
27
+ import { flowEdgeTypes, type FlowConnectionEdgeData } from './FlowConnectionEdge'
28
+ import { FlowMapCanvas } from './FlowMapCanvas'
29
+ import { mergeFlowEditorLabels, type FlowEditorLabels } from './labels'
30
+ // Operações puras do grafo, com teste próprio. As decisões que elas tomam não dão erro quando estão
31
+ // erradas: dão aresta apontando para nó apagado, ou salto que o motor do bot ignora.
32
+ import {
33
+ buildFlowEdges,
34
+ chainFrameBounds,
35
+ chainFrameNodeId,
36
+ computeMergedLayout,
37
+ countLiveByNode,
38
+ findFreeSlot,
39
+ newNodeFromSpec,
40
+ portalNodeId,
41
+ GROUP_HEADER_NODE_ID,
42
+ type FlowEdgeSpec,
43
+ type FlowLivePositionInput,
44
+ } from './flowCanvasModel'
45
+ import {
46
+ applyConnection,
47
+ clearConnection,
48
+ mergedFlowKeysFrom,
49
+ namespaceNodeId,
50
+ parseNamespacedId,
51
+ removeNodeAndCleanRefs,
52
+ resolveConnection,
53
+ } from './flowEditorOps'
54
+ import { placeFloatingPanel, type FloatingPlacement } from './flowMenuPlacement'
55
+ import {
56
+ computeAutoLayout,
57
+ targetsOf,
58
+ validateGraph,
59
+ isCrossFlowTarget,
60
+ crossFlowKey,
61
+ findCollectionChains,
62
+ estimateNodeHeight,
63
+ NODE_CARD_WIDTH,
64
+ type FlowGraphData,
65
+ type FlowNodeData,
66
+ type GraphIssue,
67
+ } from './flowGraph'
68
+ import { TooltipLayer } from '../Tooltip'
69
+
70
+ const RF_NODE_TYPES = {
71
+ ...flowNodeTypes,
72
+ ...flowPortalNodeTypes,
73
+ ...flowGroupHeaderNodeTypes,
74
+ ...flowGroupFrameNodeTypes,
75
+ }
76
+
77
+ /**
78
+ * A legenda lê as mesmas constantes que pintam as arestas — chave que diverge do desenho é pior que
79
+ * chave nenhuma, porque ensina errado com ar de autoridade.
80
+ */
81
+ function legendEdgeSamples(labels: FlowEditorLabels): FlowLegendEdgeSample[] {
82
+ return [
83
+ { color: EDGE_COLOR_LINEAR, label: labels.legendPanel.linear },
84
+ { color: EDGE_COLOR_BRANCH, label: labels.legendPanel.branch },
85
+ { color: EDGE_COLOR_FALLBACK, dash: '5 4', label: labels.legendPanel.fallback },
86
+ { color: EDGE_COLOR_CROSS_FLOW, dash: '3 3', label: labels.legendPanel.crossFlow },
87
+ { color: EDGE_COLOR_LIVE, label: labels.legendPanel.live },
88
+ ]
89
+ }
90
+
91
+ const LEGEND_NODE_SWATCHES = (Object.keys(NODE_TYPE_COLOR) as (keyof typeof NODE_TYPE_COLOR)[]).map((type) => ({
92
+ type,
93
+ className: NODE_TYPE_COLOR[type],
94
+ }))
95
+
96
+ const CHAIN_FRAME_PADDING = 36
97
+ /** Coluna à direita do card de origem em que o nó criado pelo "+" nasce. */
98
+ const QUICK_ADD_COLUMN_GAP = 320
99
+ const FLOW_KEY_PATTERN = /^[a-z0-9_]{2,40}$/
100
+
101
+ const EDGE_COLOR_LINEAR = '#94a3b8'
102
+
103
+ /** Enquadramento ao focar um fluxo pela aba. */
104
+ const FOCUS_MAX_ZOOM = 1
105
+ const FOCUS_PADDING = 0.2
106
+ const FOCUS_DURATION_MS = 400
107
+
108
+ /** Folga entre o "+" e o menu que ele abre. */
109
+ const QUICK_ADD_MENU_GAP = 12
110
+ const EDGE_COLOR_BRANCH = '#8b5cf6'
111
+ const EDGE_COLOR_FALLBACK = '#cbd5e1'
112
+ const EDGE_COLOR_LIVE = '#3b82f6'
113
+ const EDGE_COLOR_CROSS_FLOW = '#06b6d4'
114
+ const BACKGROUND_COLOR_LIGHT = '#cbd5e1'
115
+ const BACKGROUND_COLOR_DARK = '#334155'
116
+
117
+ // Os formatos de posição viva moram no modelo, que é quem conta — e são reexportados aqui porque
118
+ // fazem parte da api que o produto implementa. Declarar nos dois lugares faria as duas formas
119
+ // divergirem em silêncio.
120
+ export type { FlowLivePosition, FlowLiveNodeCount, FlowLivePositionInput } from './flowCanvasModel'
121
+
122
+ export interface CreateFlowInput {
123
+ key: string
124
+ label: string
125
+ showInMenu: boolean
126
+ /** Ausente quando `showInMenu` é falso — não há opção de menu para rotular. */
127
+ menuOptionLabel?: string
128
+ }
129
+
130
+ /**
131
+ * Backend de fluxos do host. Funções cruas em vez de um cliente HTTP: o pacote roda em produtos com
132
+ * axios, fetch e react-query, e nenhum deles precisa entrar como dependência daqui.
133
+ */
134
+ export interface FlowsWorkspaceApi {
135
+ getGraphs(): Promise<Record<string, FlowGraphData>>
136
+ saveGraph(key: string, graph: FlowGraphData): Promise<void>
137
+ /**
138
+ * Criar e excluir fluxo são **opcionais por capacidade**: produto cujos fluxos vêm de um seed
139
+ * versionado não expõe rota para isso, e a tela simplesmente não desenha os botões — em vez de
140
+ * oferecer uma ação que estoura no clique.
141
+ */
142
+ createFlow?(input: CreateFlowInput): Promise<void>
143
+ deleteFlow?(key: string): Promise<void>
144
+ /**
145
+ * Onde estão as conversas vivas. Ausente, os cards não pulsam e nada é consultado.
146
+ *
147
+ * Aceita a linha por sessão e a linha já agregada por nó — `meta-whatsapp-module` responde a
148
+ * segunda, e exigir a primeira deixava os cards parados em todo produto que usa o módulo.
149
+ */
150
+ getLivePositions?(): Promise<readonly FlowLivePositionInput[]>
151
+ }
152
+
153
+ export interface FlowsWorkspaceProps {
154
+ readonly api: FlowsWorkspaceApi
155
+ /** Fluxo raiz — o que abre por padrão e o único que não pode ser excluído. */
156
+ readonly rootFlowKey?: string
157
+ readonly labels?: Partial<FlowEditorLabels>
158
+ /** Kinds de ação do produto oferecidos na paleta (`trigger_simulation`, `abrir_comanda`…). */
159
+ readonly actionOptions?: readonly FlowPaletteActionOption[]
160
+ /** Seletor de arquivos do nó `send_media` — a biblioteca é do host, então entra por slot. */
161
+ // Recebe o grafo junto do nó: com fluxos fundidos, o nó em edição pode pertencer a um fluxo que
162
+ // não é o raiz, e o seletor do host precisa da chave dele para saber onde gravar.
163
+ readonly renderMediaPicker?: (node: FlowNodeData, graph: FlowGraphData) => ReactNode
164
+ /** Intervalo do polling de posições vivas. Só tem efeito com `getLivePositions`. */
165
+ readonly livePollIntervalMs?: number
166
+ /**
167
+ * Título e subtítulo do editor. `false` deixa só a barra de ações.
168
+ *
169
+ * Produto cuja navegação já nomeia a tela mostrava o nome duas vezes, em dois tamanhos, porque a
170
+ * tipografia daqui é do pacote e a de lá é do host. Esconder é a única saída que não força o
171
+ * pacote a adivinhar a escala tipográfica de cada produto.
172
+ */
173
+ readonly showHeader?: boolean
174
+ readonly className?: string
175
+ }
176
+
177
+ /**
178
+ * Traduz o papel da aresta em traço, cor e seta.
179
+ *
180
+ * O estilo fica aqui e a topologia fica no modelo, de propósito: destino errado é invisível até a
181
+ * conversa do cliente parar; cor errada aparece na primeira olhada.
182
+ */
183
+ function styleEdge(spec: FlowEdgeSpec, params: { disconnectLabel: string; onDisconnect: (spec: FlowEdgeSpec) => void }): Edge {
184
+ const color = spec.crossFlow
185
+ ? EDGE_COLOR_CROSS_FLOW
186
+ : spec.kind === 'fallback'
187
+ ? EDGE_COLOR_FALLBACK
188
+ : spec.live
189
+ ? EDGE_COLOR_LIVE
190
+ : spec.kind === 'branch'
191
+ ? EDGE_COLOR_BRANCH
192
+ : EDGE_COLOR_LINEAR
193
+ const baseWidth = spec.kind === 'branch' ? 1.75 : 1.5
194
+ const dash = spec.crossFlow ? '3 3' : spec.kind === 'fallback' ? '5 4' : undefined
195
+ const markerSize = spec.kind === 'fallback' ? 16 : 18
196
+
197
+ return {
198
+ id: spec.id,
199
+ source: spec.source,
200
+ target: spec.target,
201
+ ...(spec.sourceHandle === undefined ? {} : { sourceHandle: spec.sourceHandle }),
202
+ type: 'flowConnection',
203
+ reconnectable: 'target',
204
+ data: {
205
+ disconnectLabel: params.disconnectLabel,
206
+ onDisconnect: () => params.onDisconnect(spec),
207
+ } satisfies FlowConnectionEdgeData,
208
+ animated: spec.live,
209
+ style: {
210
+ stroke: color,
211
+ strokeWidth: spec.live && !spec.crossFlow ? 2.5 : baseWidth,
212
+ ...(dash === undefined ? {} : { strokeDasharray: dash }),
213
+ },
214
+ markerEnd: { type: MarkerType.ArrowClosed, color, width: markerSize, height: markerSize },
215
+ }
216
+ }
217
+
218
+
219
+
220
+ function extractErrorMessage(error: unknown): string | undefined {
221
+ if (error instanceof Error) return error.message
222
+ return undefined
223
+ }
224
+
225
+ const OUTLINE_BUTTON =
226
+ 'inline-flex items-center gap-1.5 rounded-lg border border-gray-200 dark:border-gray-700 px-3 py-1.5 text-xs font-medium text-gray-600 dark:text-gray-300 hover:border-blue-300 disabled:opacity-40 disabled:cursor-not-allowed'
227
+ const PRIMARY_BUTTON =
228
+ '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 disabled:opacity-40 disabled:cursor-not-allowed'
229
+ const DIALOG_INPUT =
230
+ 'w-full 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'
231
+
232
+ /**
233
+ * Editor de fluxograma completo — barra de ações, abas de fluxo, paleta, canvas com fusão
234
+ * editável, painel de nó, mapa de fluxos e diálogos de criar/excluir.
235
+ *
236
+ * É a tela inteira, não as peças: cada produto que remontava esse grid à mão acabava com uma
237
+ * versão diferente do mesmo editor. Customização entra por `labels`, `actionOptions` e
238
+ * `renderMediaPicker` — nunca por cópia do arquivo.
239
+ */
240
+ export function FlowsWorkspace({
241
+ api,
242
+ rootFlowKey = 'menu',
243
+ labels: labelsOverride,
244
+ actionOptions,
245
+ renderMediaPicker,
246
+ livePollIntervalMs = 5000,
247
+ showHeader = true,
248
+ className,
249
+ }: FlowsWorkspaceProps) {
250
+ const labels = useMemo(() => mergeFlowEditorLabels(labelsOverride), [labelsOverride])
251
+ const isDark = useIsDarkTheme()
252
+
253
+ const [graphs, setGraphs] = useState<Record<string, FlowGraphData> | undefined>(undefined)
254
+ const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>('loading')
255
+ const [livePositions, setLivePositions] = useState<readonly FlowLivePositionInput[] | undefined>(undefined)
256
+ const [viewMode, setViewMode] = useState<'detail' | 'map'>('detail')
257
+ const [openFlowKeys, setOpenFlowKeys] = useState<readonly string[]>([rootFlowKey])
258
+ const [hasAutoMerged, setHasAutoMerged] = useState(false)
259
+ const [workingGraphs, setWorkingGraphs] = useState<Record<string, FlowGraphData>>({})
260
+ const [editingRef, setEditingRef] = useState<{ flowKey: string; nodeId: string } | null>(null)
261
+ const [saveState, setSaveState] = useState<'idle' | 'saving' | 'success' | 'error'>('idle')
262
+ const [saveErrorMessage, setSaveErrorMessage] = useState<string | undefined>(undefined)
263
+ const [showCreateDialog, setShowCreateDialog] = useState(false)
264
+ const [showDeleteDialog, setShowDeleteDialog] = useState(false)
265
+ /** Saída de onde o "+" foi clicado, e o ponto da tela onde ancorar o menu. */
266
+ const [quickAddFrom, setQuickAddFrom] = useState<
267
+ { flowKey: string; nodeId: string; handle: string; anchor: { x: number; y: number } } | null
268
+ >(null)
269
+ const [newFlow, setNewFlow] = useState({ key: '', label: '', showInMenu: false, menuOptionLabel: '' })
270
+ const [flowMutationState, setFlowMutationState] = useState<{ pending: boolean; error?: string }>({ pending: false })
271
+ const [rfNodes, setRfNodes] = useState<Node[]>([])
272
+ const [flowInstance, setFlowInstance] = useState<ReactFlowInstance | null>(null)
273
+ const [pendingFocusNodeId, setPendingFocusNodeId] = useState<string | null>(null)
274
+ const [pendingFocusFlowKey, setPendingFocusFlowKey] = useState<string | null>(null)
275
+ const quickAddMenuRef = useRef<HTMLDivElement>(null)
276
+ const [quickAddPlacement, setQuickAddPlacement] = useState<FloatingPlacement | null>(null)
277
+
278
+ const reloadGraphs = useCallback(async () => {
279
+ try {
280
+ const loaded = await api.getGraphs()
281
+ setGraphs(loaded)
282
+ setLoadState('ready')
283
+ return loaded
284
+ } catch {
285
+ setLoadState('error')
286
+ return undefined
287
+ }
288
+ }, [api])
289
+
290
+ useEffect(() => {
291
+ void reloadGraphs()
292
+ }, [reloadGraphs])
293
+
294
+ // Polling das posições vivas. `active` corta a resposta que chega depois do desmonte — o
295
+ // intervalo é longo o bastante para uma resposta lenta atravessar a troca de tela.
296
+ useEffect(() => {
297
+ const fetchLive = api.getLivePositions
298
+ if (!fetchLive) return
299
+ let active = true
300
+ async function poll(): Promise<void> {
301
+ try {
302
+ const positions = await fetchLive!()
303
+ if (active) setLivePositions(positions)
304
+ } catch {
305
+ // Contagem viva é enfeite: falhar aqui não pode derrubar o editor.
306
+ }
307
+ }
308
+ void poll()
309
+ const timer = setInterval(() => void poll(), livePollIntervalMs)
310
+ return () => {
311
+ active = false
312
+ clearInterval(timer)
313
+ }
314
+ }, [api, livePollIntervalMs])
315
+
316
+ const primaryFlowKey = openFlowKeys[0] ?? rootFlowKey
317
+ const primaryGraph = workingGraphs[primaryFlowKey]
318
+
319
+ // Assim que os fluxos carregam pela primeira vez, mescla automaticamente todo o fecho
320
+ // transitivo referenciado a partir da raiz — "o fluxo completo" aparece de cara, sem precisar
321
+ // clicar em cada portal. Só roda uma vez (hasAutoMerged); depois disso, focar/mesclar/fechar
322
+ // fica inteiramente sob controle do usuário.
323
+ useEffect(() => {
324
+ if (!graphs || hasAutoMerged) return
325
+ setOpenFlowKeys(mergedFlowKeysFrom(rootFlowKey, graphs))
326
+ setHasAutoMerged(true)
327
+ }, [graphs, hasAutoMerged, rootFlowKey])
328
+
329
+ // Semeia o rascunho local de qualquer fluxo recém-aberto (seleção inicial, foco ou fusão) —
330
+ // nunca sobrescreve um fluxo que já tem rascunho (preserva edições não publicadas mesmo em
331
+ // recargas de fundo).
332
+ useEffect(() => {
333
+ if (!graphs) return
334
+ setWorkingGraphs((prev) => {
335
+ let changed = false
336
+ const next = { ...prev }
337
+ for (const key of openFlowKeys) {
338
+ if (!next[key] && graphs[key]) {
339
+ next[key] = graphs[key]
340
+ changed = true
341
+ }
342
+ }
343
+ return changed ? next : prev
344
+ })
345
+ }, [graphs, openFlowKeys])
346
+
347
+ const isFlowDirty = useCallback(
348
+ (key: string): boolean => {
349
+ const working = workingGraphs[key]
350
+ const server = graphs?.[key]
351
+ if (!working || !server) return false
352
+ return JSON.stringify(working) !== JSON.stringify(server)
353
+ },
354
+ [workingGraphs, graphs],
355
+ )
356
+
357
+ const issuesByFlow = useMemo<Record<string, GraphIssue[]>>(() => {
358
+ const map: Record<string, GraphIssue[]> = {}
359
+ for (const key of openFlowKeys) {
360
+ const graph = workingGraphs[key]
361
+ if (graph) map[key] = validateGraph(graph, labels.validation)
362
+ }
363
+ return map
364
+ }, [openFlowKeys, workingGraphs, labels])
365
+
366
+ const errorCount = Object.values(issuesByFlow).reduce(
367
+ (sum, list) => sum + list.filter((issue) => issue.severity === 'error').length,
368
+ 0,
369
+ )
370
+ const warningCount = Object.values(issuesByFlow).reduce(
371
+ (sum, list) => sum + list.filter((issue) => issue.severity === 'warning').length,
372
+ 0,
373
+ )
374
+ const dirtyKeys = openFlowKeys.filter(isFlowDirty)
375
+ const isDirty = dirtyKeys.length > 0
376
+
377
+ const updateFlow = useCallback((flowKey: string, updater: (graph: FlowGraphData) => FlowGraphData) => {
378
+ setWorkingGraphs((prev) => {
379
+ const graph = prev[flowKey]
380
+ if (!graph) return prev
381
+ return { ...prev, [flowKey]: updater(graph) }
382
+ })
383
+ }, [])
384
+
385
+ // "Focar": troca o fluxo primário e re-mescla o fecho transitivo dele (o fluxo completo
386
+ // referenciado a partir dele) — não isola mais num único fluxo sozinho, já que o padrão
387
+ // agora é sempre mostrar tudo que está conectado.
388
+ const focusFlow = useCallback(
389
+ (key: string) => {
390
+ const others = openFlowKeys.filter((each) => each !== key)
391
+ if (others.some(isFlowDirty) && !window.confirm(labels.workspace.unsavedChangesConfirm)) return
392
+ setOpenFlowKeys(graphs ? mergedFlowKeysFrom(key, graphs) : [key])
393
+ if (editingRef && editingRef.flowKey !== key) setEditingRef(null)
394
+ // Trocar o fluxo primário não movia a câmera: a aba acendia, o canvas continuava onde
395
+ // estava e o fluxo escolhido ficava fora da tela — clicar em "Consórcio" parecia não fazer
396
+ // nada. O enquadramento espera os cards existirem (ver o efeito abaixo).
397
+ setPendingFocusFlowKey(key)
398
+ },
399
+ [openFlowKeys, isFlowDirty, editingRef, graphs, labels],
400
+ )
401
+
402
+ // "Fechar": remove um fluxo mesclado sem trocar o foco do primário.
403
+ const closeFlow = useCallback(
404
+ (key: string) => {
405
+ if (isFlowDirty(key) && !window.confirm(labels.workspace.unsavedChangesConfirm)) return
406
+ setOpenFlowKeys((prev) => prev.filter((each) => each !== key))
407
+ setWorkingGraphs((prev) => {
408
+ const { [key]: _removed, ...rest } = prev
409
+ return rest
410
+ })
411
+ if (editingRef?.flowKey === key) setEditingRef(null)
412
+ },
413
+ [isFlowDirty, editingRef, labels],
414
+ )
415
+
416
+ /**
417
+ * Fusão editável: traz os nós de verdade do fluxo alvo para o mesmo canvas, no lugar do portal.
418
+ *
419
+ * Não calcula posição. Abrir um segundo fluxo liga o layout mesclado, que posiciona TODOS os nós
420
+ * junto e em coordenadas absolutas — um deslocamento calculado aqui seria descartado no desenho e
421
+ * ainda assim subtraído ao gravar, que era como a posição do card ia parar errada no grafo.
422
+ */
423
+ const mergeFlow = useCallback((targetFlowKey: string) => {
424
+ setOpenFlowKeys((prev) => (prev.includes(targetFlowKey) ? prev : [...prev, targetFlowKey]))
425
+ }, [])
426
+
427
+ // Mais de um fluxo aberto = layout global (ignora node.position individual, recalcula tudo
428
+ // junto pra nunca sobrepor); um só fluxo aberto = comportamento de sempre (respeita posição
429
+ // salva/arrastada, com fallback pro auto-layout daquele fluxo isolado).
430
+ const isMerged = openFlowKeys.length > 1
431
+ const mergedPositions = useMemo(
432
+ () => (isMerged ? computeMergedLayout({ openKeys: openFlowKeys, graphs: workingGraphs, primaryFlowKey }) : null),
433
+ [isMerged, openFlowKeys, workingGraphs, primaryFlowKey],
434
+ )
435
+
436
+ // Última posição em que cada nó foi desenhado, e ela manda sobre o layout calculado.
437
+ //
438
+ // O layout mesclado é recalculado a cada mudança de topologia: ligar ou desligar um fio mexia
439
+ // no rank de todo mundo e o canvas inteiro saltava de lugar — o card que perdeu a ligação ia
440
+ // parar na faixa dos órfãos e os demais mudavam de coluna, o que se lê como "o card sumiu".
441
+ // Aqui o layout vira só a semente de quem ainda não tem lugar; o resto fica onde está até o
442
+ // usuário arrastar ou pedir "Organizar".
443
+ const renderedPositionsRef = useRef(new Map<string, { x: number; y: number }>())
444
+
445
+ const derivedNodes = useMemo<Node[]>(() => {
446
+ const allNodes: Node[] = []
447
+ for (const flowKey of openFlowKeys) {
448
+ const graph = workingGraphs[flowKey]
449
+ if (!graph) continue
450
+ const fallbackPositions = mergedPositions ? {} : computeAutoLayout(graph)
451
+ const liveCounts = countLiveByNode({ flowKey, rootFlowKey, positions: livePositions })
452
+ const flowIssues = issuesByFlow[flowKey] ?? []
453
+ const isPrimary = flowKey === primaryFlowKey
454
+
455
+ // Quem recebe fio de alguém neste fluxo. O que sobra (fora o nó inicial) está solto: o card
456
+ // ganha contorno tracejado e pulsa, para desconectar ou criar um nó ficar visivelmente
457
+ // "falta ligar isto aqui" em vez de silencioso.
458
+ const connectedTargets = new Set<string>()
459
+ for (const candidate of Object.values(graph.nodes)) {
460
+ for (const { target } of targetsOf(candidate)) {
461
+ if (!isCrossFlowTarget(target)) connectedTargets.add(target)
462
+ }
463
+ }
464
+
465
+ function resolvePosition(nodeId: string): { x: number; y: number } {
466
+ if (mergedPositions) {
467
+ const nsId = namespaceNodeId(flowKey, nodeId)
468
+ return renderedPositionsRef.current.get(nsId) ?? mergedPositions.get(nsId) ?? { x: 0, y: 0 }
469
+ }
470
+ // Fluxo sozinho no canvas: a posição salva no grafo é a do card, sem tradução no meio.
471
+ //
472
+ // O `renderedPositionsRef` no meio é o que impede o card de sumir ao desligar um fio. Nó
473
+ // vindo do seed não tem posição salva, então quem manda nele é o auto-layout — e o
474
+ // auto-layout joga todo nó sem ligação de entrada para uma faixa ABAIXO de tudo. Desligar
475
+ // a última ação a mandava para fora da área visível no mesmo instante, o que se lê como
476
+ // "o editor apagou meu card". Congelando o lugar em que ele já foi desenhado, desligar o
477
+ // fio passa a mudar só o fio.
478
+ const nsId = namespaceNodeId(flowKey, nodeId)
479
+ return (
480
+ graph!.nodes[nodeId]?.position ??
481
+ renderedPositionsRef.current.get(nsId) ??
482
+ fallbackPositions[nodeId] ?? { x: 0, y: 0 }
483
+ )
484
+ }
485
+
486
+ for (const node of Object.values(graph.nodes)) {
487
+ const position = resolvePosition(node.id)
488
+ allNodes.push({
489
+ id: namespaceNodeId(flowKey, node.id),
490
+ type: 'flowNode',
491
+ position,
492
+ draggable: true,
493
+ data: {
494
+ node,
495
+ liveCount: liveCounts[node.id] ?? 0,
496
+ isStart: node.id === graph.startNodeId,
497
+ isSelected: editingRef?.flowKey === flowKey && editingRef?.nodeId === node.id,
498
+ isDetached: node.id !== graph.startNodeId && !connectedTargets.has(node.id),
499
+ issues: flowIssues,
500
+ labels,
501
+ onSelect: (nodeId: string) => setEditingRef({ flowKey, nodeId }),
502
+ onQuickAdd: ({ nodeId, handle, anchor }) => setQuickAddFrom({ flowKey, nodeId, handle, anchor }),
503
+ } satisfies FlowNodeCardData,
504
+ })
505
+
506
+ // Um portal por (nó de origem, fluxo alvo único) — só para saltos cujo fluxo alvo AINDA
507
+ // não está mesclado no canvas (hoje raro, já que abrir um fluxo já mescla tudo que ele
508
+ // referencia — mas serve de rede de segurança pra um fluxo criado depois da fusão
509
+ // inicial); se já estiver mesclado, buildAllEdges liga direto ao nó real.
510
+ const crossFlowTargets = [...new Set(targetsOf(node).map((edge) => edge.target).filter(isCrossFlowTarget))]
511
+ crossFlowTargets.forEach((target, index) => {
512
+ const targetFlowKey = crossFlowKey(target)
513
+ if (openFlowKeys.includes(targetFlowKey)) return
514
+ allNodes.push({
515
+ id: namespaceNodeId(flowKey, portalNodeId(node.id, target)),
516
+ type: 'flowPortal',
517
+ draggable: false,
518
+ selectable: false,
519
+ position: { x: position.x + 320, y: position.y + index * 70 },
520
+ data: {
521
+ label: graphs?.[targetFlowKey]?.label ?? targetFlowKey,
522
+ onNavigate: () => mergeFlow(targetFlowKey),
523
+ } satisfies FlowPortalNodeData,
524
+ })
525
+ })
526
+ }
527
+
528
+ // Moldura decorativa por trás de cada cadeia de perguntas lineares que alimenta uma ação —
529
+ // puramente derivada da topologia do grafo, sem precisar marcar manualmente quais perguntas
530
+ // "pertencem" ao cálculo.
531
+ for (const chain of findCollectionChains(graph)) {
532
+ const chainNodeIds = [...chain.nodeIds, chain.actionNodeId]
533
+ const positions = chainNodeIds.map((id) => resolvePosition(id))
534
+ const minX = Math.min(...positions.map((point) => point.x))
535
+ const maxX = Math.max(...positions.map((point) => point.x)) + NODE_CARD_WIDTH
536
+ const minY = Math.min(...positions.map((point) => point.y))
537
+ const maxY = Math.max(...positions.map((point) => point.y)) + estimateNodeHeight(graph.nodes[chain.actionNodeId]!)
538
+ allNodes.push({
539
+ id: namespaceNodeId(flowKey, `__chain__${chain.actionNodeId}`),
540
+ type: 'flowGroupFrame',
541
+ draggable: false,
542
+ selectable: false,
543
+ zIndex: -1,
544
+ position: { x: minX - CHAIN_FRAME_PADDING, y: minY - CHAIN_FRAME_PADDING - 24 },
545
+ style: {
546
+ width: maxX - minX + CHAIN_FRAME_PADDING * 2,
547
+ height: maxY - minY + CHAIN_FRAME_PADDING * 2 + 24,
548
+ },
549
+ data: {
550
+ label: labels.collectionChain.feeds(nodeLabel(graph.nodes[chain.actionNodeId], labels)),
551
+ } satisfies FlowGroupFrameData,
552
+ })
553
+ }
554
+
555
+ // Cabeçalho flutuante só para fluxos mesclados (o primário já tem controles na barra
556
+ // de cima — paleta, organizar, publicar, excluir).
557
+ if (!isPrimary) {
558
+ const startPosition = resolvePosition(graph.startNodeId)
559
+ allNodes.push({
560
+ id: namespaceNodeId(flowKey, '__group_header__'),
561
+ type: 'flowGroupHeader',
562
+ draggable: false,
563
+ selectable: false,
564
+ position: { x: startPosition.x, y: startPosition.y - 60 },
565
+ data: {
566
+ label: graph.label,
567
+ onFocus: () => focusFlow(flowKey),
568
+ onClose: () => closeFlow(flowKey),
569
+ } satisfies FlowGroupHeaderData,
570
+ })
571
+ }
572
+ }
573
+ return allNodes
574
+ }, [
575
+ openFlowKeys,
576
+ workingGraphs,
577
+ mergedPositions,
578
+ livePositions,
579
+ issuesByFlow,
580
+ primaryFlowKey,
581
+ rootFlowKey,
582
+ editingRef,
583
+ graphs,
584
+ mergeFlow,
585
+ focusFlow,
586
+ closeFlow,
587
+ labels,
588
+ ])
589
+
590
+ // Desligar um fio é gravar destino vazio no nó de origem — o nó do outro lado NÃO se mexe. Era
591
+ // isso que faltava: sem caminho para desligar, trocar o destino da última ação passava por
592
+ // apagar o card e refazê-lo.
593
+ const disconnectEdge = useCallback(
594
+ (spec: FlowEdgeSpec) => {
595
+ const { flowKey, nodeId } = parseNamespacedId(spec.source)
596
+ updateFlow(flowKey, (graph) => {
597
+ const node = graph.nodes[nodeId]
598
+ if (!node) return graph
599
+ return { ...graph, nodes: { ...graph.nodes, [nodeId]: clearConnection(node, spec.sourceHandle ?? 'next') } }
600
+ })
601
+ },
602
+ [updateFlow],
603
+ )
604
+
605
+ const edges = useMemo(
606
+ () =>
607
+ buildFlowEdges({ openKeys: openFlowKeys, graphs: workingGraphs, rootFlowKey, livePositions }).map((spec) =>
608
+ // Salto entre fluxos desenhado como portal não se desliga daqui: quem manda nele é o `next`
609
+ // do nó de origem, e o portal é só a caixa que representa o fluxo alvo ausente.
610
+ styleEdge(spec, { disconnectLabel: labels.quickAdd.disconnect, onDisconnect: disconnectEdge }),
611
+ ),
612
+ [openFlowKeys, workingGraphs, rootFlowKey, livePositions, labels, disconnectEdge],
613
+ )
614
+
615
+ // Arrastar a ponta de um fio para outro card: religa em UMA edição, sem passar por um estado
616
+ // intermediário em que o fluxo está quebrado.
617
+ const onReconnect = useCallback(
618
+ (oldEdge: Edge, connection: Connection) => {
619
+ const resolved = resolveConnection({
620
+ connection: {
621
+ source: connection.source,
622
+ target: connection.target,
623
+ sourceHandle: connection.sourceHandle ?? oldEdge.sourceHandle,
624
+ },
625
+ graphs: workingGraphs,
626
+ })
627
+ if (!resolved) return
628
+
629
+ updateFlow(resolved.flowKey, (graph) => {
630
+ const node = graph.nodes[resolved.nodeId]
631
+ if (!node) return graph
632
+ return { ...graph, nodes: { ...graph.nodes, [resolved.nodeId]: applyConnection(node, resolved) } }
633
+ })
634
+ },
635
+ [workingGraphs, updateFlow],
636
+ )
637
+
638
+ useEffect(() => {
639
+ setRfNodes(derivedNodes)
640
+ for (const node of derivedNodes) {
641
+ if (node.type === 'flowNode') renderedPositionsRef.current.set(node.id, node.position)
642
+ }
643
+ }, [derivedNodes])
644
+
645
+ // Nó recém-criado ainda não tem ligação, e o layout manda todo órfão para uma faixa abaixo de
646
+ // tudo — num canvas com vários fluxos mesclados isso cai longe da área visível, e o card parece
647
+ // ter sumido. Espera ele existir no canvas e leva a viewport até lá.
648
+ useEffect(() => {
649
+ if (!pendingFocusNodeId || !flowInstance) return
650
+ const target = rfNodes.find((node) => node.id === pendingFocusNodeId)
651
+ if (!target) return
652
+ flowInstance.setCenter(target.position.x + NODE_CARD_WIDTH / 2, target.position.y, { zoom: 1, duration: 400 })
653
+ setPendingFocusNodeId(null)
654
+ }, [pendingFocusNodeId, flowInstance, rfNodes])
655
+
656
+ // Enquadra o fluxo recém-focado. `fitView` restrito aos cards DELE, e não o `fitView` geral:
657
+ // o canvas mostra o fecho transitivo inteiro, e enquadrar tudo devolveria a mesma visão de
658
+ // sempre. `maxZoom` evita que um fluxo de dois nós encha a tela.
659
+ useEffect(() => {
660
+ if (!pendingFocusFlowKey || !flowInstance) return
661
+ const flowNodes = rfNodes.filter((node) => parseNamespacedId(node.id).flowKey === pendingFocusFlowKey)
662
+ if (flowNodes.length === 0) return
663
+ void flowInstance.fitView({
664
+ nodes: flowNodes.map((node) => ({ id: node.id })),
665
+ maxZoom: FOCUS_MAX_ZOOM,
666
+ padding: FOCUS_PADDING,
667
+ duration: FOCUS_DURATION_MS,
668
+ })
669
+ setPendingFocusFlowKey(null)
670
+ }, [pendingFocusFlowKey, flowInstance, rfNodes])
671
+
672
+ // O menu do "+" saía da tela quando o card estava perto da borda: a âncora era usada crua, sem
673
+ // consultar o tamanho da janela. Mede depois de montar e reposiciona antes da pintura.
674
+ useLayoutEffect(() => {
675
+ if (!quickAddFrom) {
676
+ setQuickAddPlacement(null)
677
+ return
678
+ }
679
+ const panel = quickAddMenuRef.current
680
+ if (!panel) return
681
+ const { x, y } = quickAddFrom.anchor
682
+ setQuickAddPlacement(
683
+ placeFloatingPanel({
684
+ anchor: { left: x, top: y, right: x, bottom: y },
685
+ panel: { width: panel.offsetWidth, height: panel.scrollHeight },
686
+ viewport: { width: window.innerWidth, height: window.innerHeight },
687
+ prefer: 'below',
688
+ gap: QUICK_ADD_MENU_GAP,
689
+ }),
690
+ )
691
+ }, [quickAddFrom])
692
+
693
+ const quickAddMenuStyle = {
694
+ left: quickAddPlacement?.left ?? 0,
695
+ top: quickAddPlacement?.top ?? 0,
696
+ maxHeight: quickAddPlacement?.maxHeight,
697
+ overflowY: 'auto' as const,
698
+ visibility: quickAddPlacement ? ('visible' as const) : ('hidden' as const),
699
+ }
700
+
701
+ const onNodesChange = useCallback((changes: NodeChange[]) => {
702
+ setRfNodes((current) => applyNodeChanges(changes, current))
703
+ }, [])
704
+
705
+ const onNodeDragStop = useCallback(
706
+ (_event: unknown, node: Node) => {
707
+ renderedPositionsRef.current.set(node.id, node.position)
708
+ const { flowKey, nodeId } = parseNamespacedId(node.id)
709
+ if (!openFlowKeys.includes(flowKey)) return
710
+
711
+ // A posição do card É a posição do grafo: não há mais deslocamento por fluxo a desfazer aqui.
712
+ updateFlow(flowKey, (graph) =>
713
+ graph.nodes[nodeId]
714
+ ? { ...graph, nodes: { ...graph.nodes, [nodeId]: { ...graph.nodes[nodeId]!, position: node.position } } }
715
+ : graph,
716
+ )
717
+ },
718
+ [openFlowKeys, updateFlow],
719
+ )
720
+
721
+ const onConnect = useCallback(
722
+ (connection: Connection) => {
723
+ // Traduzir o arraste e aplicar no nó são as duas decisões que mandam a conversa do cliente
724
+ // para o lugar certo ou errado, sem erro no meio — por isso vivem em `flowEditorOps`, testadas.
725
+ const resolved = resolveConnection({
726
+ connection: { source: connection.source, target: connection.target, sourceHandle: connection.sourceHandle },
727
+ graphs: workingGraphs,
728
+ })
729
+ if (!resolved) return
730
+
731
+ updateFlow(resolved.flowKey, (graph) => {
732
+ const node = graph.nodes[resolved.nodeId]
733
+ if (!node) return graph
734
+ return { ...graph, nodes: { ...graph.nodes, [resolved.nodeId]: applyConnection(node, resolved) } }
735
+ })
736
+ },
737
+ [workingGraphs, updateFlow],
738
+ )
739
+
740
+ function handleAddNode(spec: NewNodeSpec) {
741
+ if (!primaryGraph) return
742
+ const newNode = newNodeFromSpec(spec, new Set(Object.keys(primaryGraph.nodes)))
743
+ const maxY = Math.max(0, ...Object.values(primaryGraph.nodes).map((node) => node.position?.y ?? 0))
744
+ newNode.position = { x: 0, y: maxY + 170 }
745
+ updateFlow(primaryFlowKey, (graph) => ({ ...graph, nodes: { ...graph.nodes, [newNode.id]: newNode } }))
746
+ setEditingRef({ flowKey: primaryFlowKey, nodeId: newNode.id })
747
+ setPendingFocusNodeId(namespaceNodeId(primaryFlowKey, newNode.id))
748
+ }
749
+
750
+ /**
751
+ * Cria o próximo nó e liga o fio na MESMA edição.
752
+ *
753
+ * Uma edição só, e não duas, porque o desfazer é por passo: criar e ligar separados fariam um
754
+ * "desfazer" deixar o card novo solto no canvas, que é justamente o estado que ninguém quer.
755
+ * A posição sai do card de origem, à direita dele — o nó nasce onde a pessoa estava olhando,
756
+ * em vez de na faixa de órfãos abaixo de tudo.
757
+ */
758
+ function handleQuickAdd(spec: NewNodeSpec) {
759
+ const origin = quickAddFrom
760
+ setQuickAddFrom(null)
761
+ if (!origin) return
762
+
763
+ const originGraph = workingGraphs[origin.flowKey]
764
+ if (!originGraph) return
765
+
766
+ const newNode = newNodeFromSpec(spec, new Set(Object.keys(originGraph.nodes)))
767
+ const originPosition =
768
+ renderedPositionsRef.current.get(namespaceNodeId(origin.flowKey, origin.nodeId)) ??
769
+ originGraph.nodes[origin.nodeId]?.position ?? { x: 0, y: 0 }
770
+ // À direita de quem criou, e descendo se aquele lugar já tiver dono — tipicamente o próprio nó
771
+ // que acabou de perder a ligação, que é exatamente quem está naquela coluna.
772
+ const taken = openFlowKeys.flatMap((key) =>
773
+ Object.entries(workingGraphs[key]?.nodes ?? {}).map(
774
+ ([id, node]) => renderedPositionsRef.current.get(namespaceNodeId(key, id)) ?? node.position ?? { x: 0, y: 0 },
775
+ ),
776
+ )
777
+ newNode.position = findFreeSlot({
778
+ desired: { x: originPosition.x + QUICK_ADD_COLUMN_GAP, y: originPosition.y },
779
+ taken,
780
+ })
781
+ // Com fluxos mesclados quem decide o lugar é o layout, não o `position` do nó — sem semear
782
+ // aqui, o card nascia na coluna calculada, em cima do nó que acabou de ficar solto.
783
+ renderedPositionsRef.current.set(namespaceNodeId(origin.flowKey, newNode.id), newNode.position)
784
+
785
+ updateFlow(origin.flowKey, (graph) => {
786
+ const sourceNode = graph.nodes[origin.nodeId]
787
+ if (!sourceNode) return graph
788
+ const connected = applyConnection(sourceNode, {
789
+ flowKey: origin.flowKey,
790
+ nodeId: origin.nodeId,
791
+ handle: origin.handle,
792
+ targetValue: newNode.id,
793
+ })
794
+ return { ...graph, nodes: { ...graph.nodes, [origin.nodeId]: connected, [newNode.id]: newNode } }
795
+ })
796
+
797
+ setEditingRef({ flowKey: origin.flowKey, nodeId: newNode.id })
798
+ }
799
+
800
+ function handleNodePanelChange(updated: FlowNodeData) {
801
+ if (!editingRef) return
802
+ updateFlow(editingRef.flowKey, (graph) => ({ ...graph, nodes: { ...graph.nodes, [updated.id]: updated } }))
803
+ setEditingRef(null)
804
+ }
805
+
806
+ function handleNodeDelete(nodeId: string) {
807
+ if (!editingRef) return
808
+ updateFlow(editingRef.flowKey, (graph) => ({ ...graph, nodes: removeNodeAndCleanRefs(graph.nodes, nodeId) }))
809
+ setEditingRef(null)
810
+ }
811
+
812
+ // Único caminho que move card sozinho — as posições são estáveis no resto do tempo, então
813
+ // reorganizar virou uma ação explícita, inclusive com vários fluxos mesclados no canvas.
814
+ function handleOrganize() {
815
+ if (!primaryGraph) return
816
+ renderedPositionsRef.current.clear()
817
+
818
+ if (isMerged) {
819
+ const positions = computeMergedLayout({ openKeys: openFlowKeys, graphs: workingGraphs, primaryFlowKey })
820
+ for (const key of openFlowKeys) {
821
+ updateFlow(key, (graph) => ({
822
+ ...graph,
823
+ nodes: Object.fromEntries(
824
+ Object.entries(graph.nodes).map(([id, node]) => {
825
+ const position = positions.get(namespaceNodeId(key, id))
826
+ return [id, position ? { ...node, position } : node]
827
+ }),
828
+ ),
829
+ }))
830
+ }
831
+ return
832
+ }
833
+
834
+ const positions = computeAutoLayout(primaryGraph)
835
+ updateFlow(primaryFlowKey, (graph) => ({
836
+ ...graph,
837
+ nodes: Object.fromEntries(
838
+ Object.entries(graph.nodes).map(([id, node]) => [id, { ...node, position: positions[id] ?? node.position }]),
839
+ ),
840
+ }))
841
+ }
842
+
843
+ // Descarta o rascunho local e volta ao que está publicado. Sem isso, a única saída de uma
844
+ // edição indesejada era recarregar a página no susto — e recarregar também perde o resto.
845
+ function handleDiscardChanges() {
846
+ if (!graphs || !isDirty) return
847
+ if (!window.confirm(labels.workspace.discardConfirm)) return
848
+ setWorkingGraphs((prev) => {
849
+ const next = { ...prev }
850
+ for (const key of dirtyKeys) {
851
+ const published = graphs[key]
852
+ if (published) next[key] = published
853
+ }
854
+ return next
855
+ })
856
+ renderedPositionsRef.current.clear()
857
+ setEditingRef(null)
858
+ }
859
+
860
+ async function handlePublish() {
861
+ if (dirtyKeys.length === 0 || errorCount > 0) return
862
+ setSaveState('saving')
863
+ setSaveErrorMessage(undefined)
864
+ try {
865
+ await Promise.all(dirtyKeys.map((key) => api.saveGraph(key, workingGraphs[key]!)))
866
+ await reloadGraphs()
867
+ setSaveState('success')
868
+ setTimeout(() => setSaveState('idle'), 3000)
869
+ } catch (error) {
870
+ setSaveState('error')
871
+ setSaveErrorMessage(extractErrorMessage(error))
872
+ }
873
+ }
874
+
875
+ async function handleCreateFlow() {
876
+ const createFlow = api.createFlow
877
+ if (!createFlow) return
878
+ setFlowMutationState({ pending: true })
879
+ try {
880
+ await createFlow({
881
+ key: newFlow.key,
882
+ label: newFlow.label,
883
+ showInMenu: newFlow.showInMenu,
884
+ ...(newFlow.showInMenu ? { menuOptionLabel: newFlow.menuOptionLabel || newFlow.label } : {}),
885
+ })
886
+ await reloadGraphs()
887
+ setOpenFlowKeys([newFlow.key])
888
+ setShowCreateDialog(false)
889
+ setNewFlow({ key: '', label: '', showInMenu: false, menuOptionLabel: '' })
890
+ setFlowMutationState({ pending: false })
891
+ } catch (error) {
892
+ setFlowMutationState({ pending: false, error: extractErrorMessage(error) ?? labels.flowManager.createError })
893
+ }
894
+ }
895
+
896
+ async function handleDeleteFlow() {
897
+ const deleteFlow = api.deleteFlow
898
+ if (!deleteFlow || !primaryGraph) return
899
+ setFlowMutationState({ pending: true })
900
+ try {
901
+ await deleteFlow(primaryGraph.key)
902
+ const reloaded = await reloadGraphs()
903
+ setOpenFlowKeys(reloaded ? mergedFlowKeysFrom(rootFlowKey, reloaded) : [rootFlowKey])
904
+ setShowDeleteDialog(false)
905
+ setFlowMutationState({ pending: false })
906
+ } catch (error) {
907
+ setFlowMutationState({ pending: false, error: extractErrorMessage(error) ?? labels.flowManager.deleteError })
908
+ }
909
+ }
910
+
911
+ const editingGraph = editingRef ? workingGraphs[editingRef.flowKey] : null
912
+ const editingNode = editingRef && editingGraph ? editingGraph.nodes[editingRef.nodeId] : null
913
+ const otherFlows = useMemo(
914
+ () =>
915
+ Object.values(graphs ?? {})
916
+ .filter((graph) => graph.key !== editingRef?.flowKey)
917
+ .map((graph) => ({ key: graph.key, label: graph.label })),
918
+ [graphs, editingRef?.flowKey],
919
+ )
920
+ const keyIsValid = FLOW_KEY_PATTERN.test(newFlow.key)
921
+ const canCreateFlow = Boolean(api.createFlow)
922
+ const canDeleteFlow = Boolean(api.deleteFlow) && primaryFlowKey !== rootFlowKey
923
+
924
+ return (
925
+ <div className={`space-y-4 h-full flex flex-col ${className ?? ''}`}>
926
+ <TooltipLayer />
927
+ <div className="flex items-center justify-between flex-wrap gap-3">
928
+ {showHeader && (
929
+ <div className="min-w-0">
930
+ <h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">{labels.workspace.title}</h2>
931
+ <p className="text-gray-500 dark:text-gray-400 text-sm mt-1">{labels.workspace.subtitle}</p>
932
+ </div>
933
+ )}
934
+ {/* Quatro botões passam de 580px e a tela mais estreita é de 375px: sem `flex-wrap` a barra
935
+ empurrava a página inteira para o lado. `ml-auto` mantém tudo à direita mesmo sem o
936
+ título ao lado, que é o caso de `showHeader={false}`. */}
937
+ <div className="flex flex-wrap items-center justify-end gap-3 ml-auto">
938
+ {saveState === 'success' && <span className="text-sm text-green-600">{labels.workspace.saveSuccess}</span>}
939
+ {saveState === 'error' && (
940
+ <span className="text-sm text-red-600">{saveErrorMessage ?? labels.workspace.saveError}</span>
941
+ )}
942
+ <button
943
+ data-cv-tooltip={viewMode === 'map' ? labels.flowMap.toggleToDetail : labels.flowMap.toggleToMap} aria-label={viewMode === 'map' ? labels.flowMap.toggleToDetail : labels.flowMap.toggleToMap}
944
+ type="button"
945
+ onClick={() => setViewMode((mode) => (mode === 'map' ? 'detail' : 'map'))}
946
+ className={OUTLINE_BUTTON}
947
+ >
948
+ {viewMode === 'map' ? <Workflow size={14} aria-hidden="true" /> : <MapIcon size={14} aria-hidden="true" />}
949
+ {viewMode === 'map' ? labels.flowMap.toggleToDetail : labels.flowMap.toggleToMap}
950
+ </button>
951
+ {viewMode === 'detail' && (
952
+ <>
953
+ <button
954
+ type="button"
955
+ onClick={handleOrganize}
956
+ className={OUTLINE_BUTTON}
957
+ data-cv-tooltip={labels.workspace.organizeTooltip} aria-label={labels.workspace.organizeTooltip}
958
+ disabled={!primaryGraph}
959
+ >
960
+ <LayoutGrid size={14} aria-hidden="true" /> {labels.workspace.organize}
961
+ </button>
962
+ <button
963
+ type="button"
964
+ onClick={handleDiscardChanges}
965
+ className={OUTLINE_BUTTON}
966
+ data-cv-tooltip={labels.workspace.discardTooltip} aria-label={labels.workspace.discardTooltip}
967
+ disabled={!isDirty || saveState === 'saving'}
968
+ >
969
+ <Undo2 size={14} aria-hidden="true" /> {labels.workspace.discardChanges}
970
+ </button>
971
+ <button
972
+ data-cv-tooltip={labels.workspace.saveGraph} aria-label={labels.workspace.saveGraph}
973
+ type="button"
974
+ onClick={() => void handlePublish()}
975
+ className={PRIMARY_BUTTON}
976
+ disabled={!isDirty || errorCount > 0 || saveState === 'saving'}
977
+ >
978
+ <Save size={14} aria-hidden="true" />
979
+ {saveState === 'saving' ? labels.workspace.saving : labels.workspace.saveGraph}
980
+ </button>
981
+ </>
982
+ )}
983
+ </div>
984
+ </div>
985
+
986
+ {viewMode === 'detail' && (
987
+ <div className="flex items-center justify-between gap-2 flex-wrap">
988
+ <div className="flex items-center gap-2 flex-wrap">
989
+ {graphs &&
990
+ Object.values(graphs).map((graph) => (
991
+ <button
992
+ data-cv-tooltip={graph.label} aria-label={graph.label}
993
+ key={graph.key}
994
+ type="button"
995
+ onClick={() => focusFlow(graph.key)}
996
+ className={`px-3 py-1.5 rounded-full text-xs font-medium border transition-colors ${
997
+ primaryFlowKey === graph.key
998
+ ? 'bg-blue-600 text-white border-blue-600'
999
+ : 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 border-gray-200 dark:border-gray-700 hover:border-blue-300'
1000
+ }`}
1001
+ >
1002
+ {graph.label}
1003
+ </button>
1004
+ ))}
1005
+ {canCreateFlow && (
1006
+ <button
1007
+ data-cv-tooltip={labels.flowManager.newFlow} aria-label={labels.flowManager.newFlow}
1008
+ type="button"
1009
+ onClick={() => {
1010
+ setFlowMutationState({ pending: false })
1011
+ setShowCreateDialog(true)
1012
+ }}
1013
+ className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-xs font-medium border border-dashed border-gray-300 dark:border-gray-600 text-gray-500 dark:text-gray-400 hover:border-blue-400 hover:text-blue-600"
1014
+ >
1015
+ <Plus size={12} aria-hidden="true" /> {labels.flowManager.newFlow}
1016
+ </button>
1017
+ )}
1018
+ </div>
1019
+ <div className="flex items-center gap-2">
1020
+ {primaryGraph && (
1021
+ <FlowPalette
1022
+ onAdd={handleAddNode}
1023
+ labels={labels}
1024
+ {...(actionOptions ? { actionOptions: [...actionOptions] } : {})}
1025
+ />
1026
+ )}
1027
+ {primaryGraph && canDeleteFlow && (
1028
+ <button
1029
+ data-cv-tooltip={labels.flowManager.deleteFlow} aria-label={labels.flowManager.deleteFlow}
1030
+ type="button"
1031
+ onClick={() => {
1032
+ setFlowMutationState({ pending: false })
1033
+ setShowDeleteDialog(true)
1034
+ }}
1035
+ className="inline-flex items-center gap-1.5 rounded-lg border border-red-200 dark:border-red-900 px-3 py-1.5 text-xs font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/30"
1036
+ >
1037
+ <Trash2 size={13} aria-hidden="true" /> {labels.flowManager.deleteFlow}
1038
+ </button>
1039
+ )}
1040
+ </div>
1041
+ </div>
1042
+ )}
1043
+
1044
+ {viewMode === 'detail' && (errorCount > 0 || warningCount > 0) && (
1045
+ <div className="flex items-center gap-4 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/60 px-3 py-2 text-xs">
1046
+ <span className="font-medium text-gray-600 dark:text-gray-300">{labels.validation.title}:</span>
1047
+ {errorCount > 0 && (
1048
+ <span className="flex items-center gap-1 text-red-600 dark:text-red-400 font-medium">
1049
+ <AlertCircle size={13} aria-hidden="true" /> {labels.validation.errors(errorCount)}
1050
+ </span>
1051
+ )}
1052
+ {warningCount > 0 && (
1053
+ <span className="flex items-center gap-1 text-amber-600 dark:text-amber-400">
1054
+ <AlertTriangle size={13} aria-hidden="true" /> {labels.validation.warnings(warningCount)}
1055
+ </span>
1056
+ )}
1057
+ </div>
1058
+ )}
1059
+
1060
+ <div className="flex-1 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden relative">
1061
+ {loadState === 'loading' && <p className="text-center text-gray-400 py-12">{labels.workspace.loading}</p>}
1062
+ {loadState === 'error' && <p className="text-center text-red-500 py-12">{labels.workspace.loadError}</p>}
1063
+ {loadState === 'ready' && graphs && viewMode === 'map' && (
1064
+ <FlowMapCanvas
1065
+ graphs={graphs}
1066
+ rootKey={rootFlowKey}
1067
+ labels={labels}
1068
+ onOpenFlow={(key) => {
1069
+ focusFlow(key)
1070
+ setViewMode('detail')
1071
+ }}
1072
+ />
1073
+ )}
1074
+ {loadState === 'ready' && viewMode === 'detail' && primaryGraph && (
1075
+ <ReactFlow
1076
+ nodes={rfNodes}
1077
+ edges={edges}
1078
+ nodeTypes={RF_NODE_TYPES}
1079
+ edgeTypes={flowEdgeTypes}
1080
+ onNodesChange={onNodesChange}
1081
+ onNodeDragStop={onNodeDragStop}
1082
+ onConnect={onConnect}
1083
+ onReconnect={onReconnect}
1084
+ onInit={setFlowInstance}
1085
+ fitView
1086
+ proOptions={{ hideAttribution: true }}
1087
+ colorMode={isDark ? 'dark' : 'light'}
1088
+ >
1089
+ <Background color={isDark ? BACKGROUND_COLOR_DARK : BACKGROUND_COLOR_LIGHT} />
1090
+ <Controls />
1091
+ <Panel position="top-right">
1092
+ <FlowLegend
1093
+ labels={labels}
1094
+ edgeSamples={legendEdgeSamples(labels)}
1095
+ nodeSwatches={LEGEND_NODE_SWATCHES}
1096
+ />
1097
+ </Panel>
1098
+ <MiniMap pannable zoomable className="!bg-white dark:!bg-gray-800" />
1099
+ </ReactFlow>
1100
+ )}
1101
+ </div>
1102
+
1103
+ {/* Menu do "+": ancorado no ponto clicado e em coordenadas de tela (`fixed`), porque o canvas
1104
+ tem pan e zoom próprios — posicionar dentro dele faria o menu escorregar junto. */}
1105
+ {quickAddFrom && (
1106
+ <>
1107
+ <div className="fixed inset-0 z-40" onClick={() => setQuickAddFrom(null)} />
1108
+ <div
1109
+ ref={quickAddMenuRef}
1110
+ className="fixed z-50 w-64 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-lg py-1"
1111
+ style={quickAddMenuStyle}
1112
+ >
1113
+ <p className="px-3 py-1.5 text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">
1114
+ {labels.quickAdd.title}
1115
+ </p>
1116
+ <FlowPaletteMenu
1117
+ onSelect={handleQuickAdd}
1118
+ labels={labels}
1119
+ {...(actionOptions ? { actionOptions: [...actionOptions] } : {})}
1120
+ />
1121
+ </div>
1122
+ </>
1123
+ )}
1124
+
1125
+ {/* `key` por nó: o painel guarda um rascunho local em estado, e sem remontar ao trocar de nó
1126
+ selecionado ele seguia mostrando (e salvando) os campos do nó anterior. */}
1127
+ {editingNode && editingGraph && (
1128
+ <FlowNodePanel
1129
+ key={`${editingRef?.flowKey}:${editingRef?.nodeId}`}
1130
+ graph={editingGraph}
1131
+ node={editingNode}
1132
+ issues={editingRef ? (issuesByFlow[editingRef.flowKey] ?? []) : []}
1133
+ otherFlows={otherFlows}
1134
+ labels={labels}
1135
+ onClose={() => setEditingRef(null)}
1136
+ onChange={handleNodePanelChange}
1137
+ onDelete={handleNodeDelete}
1138
+ {...(renderMediaPicker ? { renderMediaPicker } : {})}
1139
+ />
1140
+ )}
1141
+
1142
+ {showCreateDialog && canCreateFlow && (
1143
+ <FlowDialog title={labels.flowManager.createTitle} onClose={() => setShowCreateDialog(false)}>
1144
+ <div className="space-y-3">
1145
+ <div>
1146
+ <label className="text-xs font-medium text-gray-500 dark:text-gray-400">{labels.flowManager.label}</label>
1147
+ <input
1148
+ value={newFlow.label}
1149
+ onChange={(event) => setNewFlow((prev) => ({ ...prev, label: event.target.value }))}
1150
+ className={`mt-1 ${DIALOG_INPUT}`}
1151
+ />
1152
+ </div>
1153
+ <div>
1154
+ <label className="text-xs font-medium text-gray-500 dark:text-gray-400">{labels.flowManager.key}</label>
1155
+ <input
1156
+ value={newFlow.key}
1157
+ onChange={(event) => setNewFlow((prev) => ({ ...prev, key: event.target.value.toLowerCase() }))}
1158
+ className={`mt-1 ${DIALOG_INPUT}`}
1159
+ />
1160
+ <p className="text-[11px] text-gray-400 mt-1">
1161
+ {newFlow.key && !keyIsValid ? labels.flowManager.keyInvalid : labels.flowManager.keyHint}
1162
+ </p>
1163
+ </div>
1164
+ <label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200">
1165
+ <input
1166
+ type="checkbox"
1167
+ checked={newFlow.showInMenu}
1168
+ onChange={(event) => setNewFlow((prev) => ({ ...prev, showInMenu: event.target.checked }))}
1169
+ />
1170
+ {labels.flowManager.showInMenu}
1171
+ </label>
1172
+ {newFlow.showInMenu && (
1173
+ <div>
1174
+ <label className="text-xs font-medium text-gray-500 dark:text-gray-400">
1175
+ {labels.flowManager.menuOptionLabel}
1176
+ </label>
1177
+ <input
1178
+ value={newFlow.menuOptionLabel}
1179
+ onChange={(event) => setNewFlow((prev) => ({ ...prev, menuOptionLabel: event.target.value }))}
1180
+ placeholder={newFlow.label}
1181
+ className={`mt-1 ${DIALOG_INPUT}`}
1182
+ />
1183
+ </div>
1184
+ )}
1185
+ {flowMutationState.error && <p className="text-xs text-red-600">{flowMutationState.error}</p>}
1186
+ </div>
1187
+ <div className="flex justify-end gap-2 mt-4">
1188
+ <button data-cv-tooltip={labels.nodePanel.cancel} aria-label={labels.nodePanel.cancel} type="button" className={OUTLINE_BUTTON} onClick={() => setShowCreateDialog(false)}>
1189
+ {labels.nodePanel.cancel}
1190
+ </button>
1191
+ <button
1192
+ data-cv-tooltip={labels.flowManager.create} aria-label={labels.flowManager.create}
1193
+ type="button"
1194
+ className={PRIMARY_BUTTON}
1195
+ onClick={() => void handleCreateFlow()}
1196
+ disabled={!keyIsValid || !newFlow.label || flowMutationState.pending}
1197
+ >
1198
+ {flowMutationState.pending ? labels.flowManager.creating : labels.flowManager.create}
1199
+ </button>
1200
+ </div>
1201
+ </FlowDialog>
1202
+ )}
1203
+
1204
+ {showDeleteDialog && canDeleteFlow && (
1205
+ <FlowDialog title={labels.flowManager.deleteFlow} onClose={() => setShowDeleteDialog(false)}>
1206
+ <p className="text-sm text-gray-600 dark:text-gray-300">
1207
+ {primaryGraph ? labels.flowManager.deleteConfirm(primaryGraph.label) : ''}
1208
+ </p>
1209
+ {flowMutationState.error && <p className="text-xs text-red-600 mt-2">{flowMutationState.error}</p>}
1210
+ <div className="flex justify-end gap-2 mt-4">
1211
+ <button data-cv-tooltip={labels.nodePanel.cancel} aria-label={labels.nodePanel.cancel} type="button" className={OUTLINE_BUTTON} onClick={() => setShowDeleteDialog(false)}>
1212
+ {labels.nodePanel.cancel}
1213
+ </button>
1214
+ <button
1215
+ data-cv-tooltip={labels.flowManager.deleteFlow} aria-label={labels.flowManager.deleteFlow}
1216
+ type="button"
1217
+ className="inline-flex items-center gap-1.5 rounded-lg bg-red-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-red-700 disabled:opacity-40"
1218
+ onClick={() => void handleDeleteFlow()}
1219
+ disabled={flowMutationState.pending}
1220
+ >
1221
+ <Trash2 size={13} aria-hidden="true" /> {labels.flowManager.deleteFlow}
1222
+ </button>
1223
+ </div>
1224
+ </FlowDialog>
1225
+ )}
1226
+ </div>
1227
+ )
1228
+ }
1229
+
1230
+ // Diálogo próprio em vez de depender do `Dialog` do produto: o pacote roda em três apps com
1231
+ // bibliotecas de UI diferentes, e exigir uma delas transformaria a tela composta num acoplamento.
1232
+ function FlowDialog({
1233
+ title,
1234
+ onClose,
1235
+ children,
1236
+ }: {
1237
+ title: string
1238
+ onClose: () => void
1239
+ children: ReactNode
1240
+ }) {
1241
+ return (
1242
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4">
1243
+ <div className="absolute inset-0" onClick={onClose} aria-hidden="true" />
1244
+ <div
1245
+ role="dialog"
1246
+ aria-modal="true"
1247
+ aria-label={title}
1248
+ className="relative w-full max-w-md rounded-2xl bg-white dark:bg-gray-800 p-5 shadow-xl"
1249
+ >
1250
+ <h3 className="text-base font-semibold text-gray-900 dark:text-gray-100 mb-3">{title}</h3>
1251
+ {children}
1252
+ </div>
1253
+ </div>
1254
+ )
1255
+ }