@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,39 @@
1
+ import { AlertTriangle } from 'lucide-react'
2
+
3
+ export interface StatusTicksProps {
4
+ status: string
5
+ title?: string
6
+ }
7
+
8
+ const STATUS_COLOR_CLASS: Record<string, string> = {
9
+ sent: 'text-black/40 dark:text-white/40',
10
+ delivered: 'text-black/40 dark:text-white/40',
11
+ read: 'text-sky-500',
12
+ failed: 'text-red-500',
13
+ }
14
+
15
+ function Ticks({ double }: { double: boolean }) {
16
+ return (
17
+ <svg viewBox="0 0 20 12" width="15" height="9" fill="none" xmlns="http://www.w3.org/2000/svg">
18
+ {double && (
19
+ <path d="M1 6.5L4.5 10L11 2" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
20
+ )}
21
+ <path
22
+ d={double ? 'M6 6.5L9.5 10L19 1' : 'M1 6.5L5 10.5L14.5 1'}
23
+ stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"
24
+ />
25
+ </svg>
26
+ )
27
+ }
28
+
29
+ // Paridade com financiamento-imobiliario-bot/apps/web/src/components/MessageBubble.tsx —
30
+ // mesmo path de SVG, mesma cor por status (read → sky-500, failed → red-500 com AlertTriangle).
31
+ export function StatusTicks({ status, title }: StatusTicksProps) {
32
+ const colorClass = STATUS_COLOR_CLASS[status] ?? STATUS_COLOR_CLASS.sent
33
+
34
+ return (
35
+ <span className={`cursor-help leading-none flex items-center ${colorClass}`} title={title}>
36
+ {status === 'failed' ? <AlertTriangle size={11} /> : <Ticks double={status !== 'sent'} />}
37
+ </span>
38
+ )
39
+ }
package/src/Toast.tsx ADDED
@@ -0,0 +1,140 @@
1
+ import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react'
2
+
3
+ export type ToastType = 'success' | 'error' | 'info'
4
+
5
+ interface ToastItem {
6
+ id: number
7
+ type: ToastType
8
+ message: string
9
+ }
10
+
11
+ interface ToastContextValue {
12
+ show: (type: ToastType, message: string) => void
13
+ }
14
+
15
+ const ToastContext = createContext<ToastContextValue | null>(null)
16
+
17
+ let nextId = 0
18
+
19
+ let singletonShow: ((type: ToastType, message: string) => void) | null = null
20
+
21
+ export const toast = {
22
+ show(type: ToastType, message: string) {
23
+ if (singletonShow) {
24
+ singletonShow(type, message)
25
+ }
26
+ },
27
+ }
28
+
29
+ const typeStyles: Record<ToastType, string> = {
30
+ success: 'bg-green-600',
31
+ error: 'bg-red-600',
32
+ info: 'bg-blue-600',
33
+ }
34
+
35
+ const typeIcons: Record<ToastType, ReactNode> = {
36
+ success: (
37
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
38
+ <polyline points="20 6 9 17 4 12" />
39
+ </svg>
40
+ ),
41
+ error: (
42
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
43
+ <line x1="18" y1="6" x2="6" y2="18" />
44
+ <line x1="6" y1="6" x2="18" y2="18" />
45
+ </svg>
46
+ ),
47
+ info: (
48
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
49
+ <circle cx="12" cy="12" r="10" />
50
+ <line x1="12" y1="16" x2="12" y2="12" />
51
+ <line x1="12" y1="8" x2="12.01" y2="8" />
52
+ </svg>
53
+ ),
54
+ }
55
+
56
+ function ToastItemComponent({ item, onRemove }: { item: ToastItem; onRemove: (id: number) => void }) {
57
+ const [exiting, setExiting] = useState(false)
58
+
59
+ useEffect(() => {
60
+ const timer = setTimeout(() => {
61
+ setExiting(true)
62
+ setTimeout(() => onRemove(item.id), 300)
63
+ }, 5000)
64
+ return () => clearTimeout(timer)
65
+ }, [item.id, onRemove])
66
+
67
+ return (
68
+ <div
69
+ className={`flex items-center gap-2 px-4 py-3 rounded-lg shadow-lg text-white text-sm min-w-[280px] max-w-[400px] ${
70
+ typeStyles[item.type]
71
+ } ${exiting ? 'animate-slide-out' : 'animate-slide-in'}`}
72
+ >
73
+ <span className="flex-shrink-0">{typeIcons[item.type]}</span>
74
+ <span className="flex-1">{item.message}</span>
75
+ <button
76
+ onClick={() => {
77
+ setExiting(true)
78
+ setTimeout(() => onRemove(item.id), 300)
79
+ }}
80
+ className="flex-shrink-0 opacity-70 hover:opacity-100 transition-opacity"
81
+ >
82
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
83
+ <line x1="18" y1="6" x2="6" y2="18" />
84
+ <line x1="6" y1="6" x2="18" y2="18" />
85
+ </svg>
86
+ </button>
87
+ </div>
88
+ )
89
+ }
90
+
91
+ export function ToastProvider({ children }: { children: ReactNode }) {
92
+ const [items, setItems] = useState<ToastItem[]>([])
93
+
94
+ const show = useCallback((type: ToastType, message: string) => {
95
+ const id = ++nextId
96
+ setItems((prev) => [...prev, { id, type, message }])
97
+ }, [])
98
+
99
+ const remove = useCallback((id: number) => {
100
+ setItems((prev) => prev.filter((item) => item.id !== id))
101
+ }, [])
102
+
103
+ useEffect(() => {
104
+ singletonShow = show
105
+ return () => {
106
+ singletonShow = null
107
+ }
108
+ }, [show])
109
+
110
+ return (
111
+ <ToastContext.Provider value={{ show }}>
112
+ {children}
113
+ <div className="fixed top-4 right-4 z-[100] flex flex-col gap-2">
114
+ {items.map((item) => (
115
+ <ToastItemComponent key={item.id} item={item} onRemove={remove} />
116
+ ))}
117
+ </div>
118
+ <style>{`
119
+ @keyframes slide-in {
120
+ from { transform: translateX(100%); opacity: 0; }
121
+ to { transform: translateX(0); opacity: 1; }
122
+ }
123
+ @keyframes slide-out {
124
+ from { transform: translateX(0); opacity: 1; }
125
+ to { transform: translateX(100%); opacity: 0; }
126
+ }
127
+ .animate-slide-in { animation: slide-in 0.3s ease-out; }
128
+ .animate-slide-out { animation: slide-out 0.3s ease-in forwards; }
129
+ `}</style>
130
+ </ToastContext.Provider>
131
+ )
132
+ }
133
+
134
+ export function useToast(): ToastContextValue {
135
+ const context = useContext(ToastContext)
136
+ if (!context) {
137
+ throw new Error('useToast must be used within a ToastProvider')
138
+ }
139
+ return context
140
+ }
@@ -0,0 +1,13 @@
1
+ import type { ReactNode } from 'react'
2
+
3
+ export interface ConversationWallpaperProps {
4
+ children?: ReactNode
5
+ className?: string
6
+ }
7
+
8
+ // Requer o import de '@adatechnology/conversations-ui/styles.css' uma vez no host —
9
+ // a classe `.cv-wallpaper` (e sua variante `.dark`) vive nesse stylesheet, não em CSS-in-JS,
10
+ // para não depender da configuração de Tailwind do consumidor.
11
+ export function ConversationWallpaper({ children, className = '' }: ConversationWallpaperProps) {
12
+ return <div className={`cv-wallpaper ${className}`.trim()}>{children}</div>
13
+ }
@@ -0,0 +1,142 @@
1
+ import { useRef, type ReactNode } from 'react'
2
+ import { Bold, Italic, Strikethrough } from 'lucide-react'
3
+
4
+ export interface WhatsAppMessageEditorProps {
5
+ value: string
6
+ onChange: (value: string) => void
7
+ placeholder?: string
8
+ placeholders?: readonly string[]
9
+ rows?: number
10
+ previewLabel?: string
11
+ emptyPreviewText?: string
12
+ }
13
+
14
+ const INLINE_TOKEN = /(\*[^*\n]+\*|_[^_\n]+_|~[^~\n]+~|`[^`\n]+`|\{[a-zA-Z_]+\})/g
15
+
16
+ function renderLine(line: string, lineKey: string): ReactNode[] {
17
+ const nodes: ReactNode[] = []
18
+ let lastIndex = 0
19
+ let match: RegExpExecArray | null
20
+ let tokenIndex = 0
21
+ INLINE_TOKEN.lastIndex = 0
22
+ while ((match = INLINE_TOKEN.exec(line)) !== null) {
23
+ if (match.index > lastIndex) nodes.push(line.slice(lastIndex, match.index))
24
+ const token = match[0]
25
+ const key = `${lineKey}-${tokenIndex++}`
26
+ if (token.startsWith('*')) nodes.push(<strong key={key}>{token.slice(1, -1)}</strong>)
27
+ else if (token.startsWith('_')) nodes.push(<em key={key}>{token.slice(1, -1)}</em>)
28
+ else if (token.startsWith('~')) nodes.push(<s key={key}>{token.slice(1, -1)}</s>)
29
+ else if (token.startsWith('`')) nodes.push(<code key={key} className="font-mono text-[0.85em]">{token.slice(1, -1)}</code>)
30
+ else nodes.push(<span key={key} className="rounded bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 px-1">{token}</span>)
31
+ lastIndex = INLINE_TOKEN.lastIndex
32
+ }
33
+ if (lastIndex < line.length) nodes.push(line.slice(lastIndex))
34
+ return nodes
35
+ }
36
+
37
+ function renderWhatsApp(text: string): ReactNode[] {
38
+ const lines = text.split('\n')
39
+ return lines.map((line, index) => (
40
+ <span key={`ln-${index}`}>
41
+ {renderLine(line, `ln-${index}`)}
42
+ {index < lines.length - 1 ? <br /> : null}
43
+ </span>
44
+ ))
45
+ }
46
+
47
+ // Paridade com financiamento-imobiliario-bot/apps/web/src/components/WhatsAppMessageEditor.tsx —
48
+ // editor para autoria de templates/mensagens padrão (welcome, farewell, etc.), com toolbar de
49
+ // formatação, inserção de placeholders de variável e preview no estilo balão do WhatsApp.
50
+ // Diferente de MessageComposer (caixa de envio de uma conversa ao vivo).
51
+ export function WhatsAppMessageEditor({
52
+ value,
53
+ onChange,
54
+ placeholder,
55
+ placeholders = [],
56
+ rows = 4,
57
+ previewLabel = 'Prévia (como aparece no WhatsApp)',
58
+ emptyPreviewText = 'Sua mensagem aparecerá aqui…',
59
+ }: WhatsAppMessageEditorProps) {
60
+ const textareaRef = useRef<HTMLTextAreaElement>(null)
61
+
62
+ function wrapSelection(marker: string): void {
63
+ const textarea = textareaRef.current
64
+ if (!textarea) return
65
+ const start = textarea.selectionStart
66
+ const end = textarea.selectionEnd
67
+ const selected = value.slice(start, end) || 'texto'
68
+ const next = value.slice(0, start) + marker + selected + marker + value.slice(end)
69
+ onChange(next)
70
+ requestAnimationFrame(() => {
71
+ textarea.focus()
72
+ textarea.setSelectionRange(start + marker.length, start + marker.length + selected.length)
73
+ })
74
+ }
75
+
76
+ function insertAtCursor(snippet: string): void {
77
+ const textarea = textareaRef.current
78
+ if (!textarea) return
79
+ const start = textarea.selectionStart
80
+ const end = textarea.selectionEnd
81
+ const next = value.slice(0, start) + snippet + value.slice(end)
82
+ onChange(next)
83
+ requestAnimationFrame(() => {
84
+ textarea.focus()
85
+ const caret = start + snippet.length
86
+ textarea.setSelectionRange(caret, caret)
87
+ })
88
+ }
89
+
90
+ const toolbarButtonClass =
91
+ 'inline-flex items-center justify-center h-8 w-8 rounded-lg border border-gray-200 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors'
92
+
93
+ return (
94
+ <div>
95
+ <div className="flex flex-wrap items-center gap-1.5 mb-2">
96
+ <button type="button" onClick={() => wrapSelection('*')} className={toolbarButtonClass} title="Negrito (*texto*)" aria-label="Negrito">
97
+ <Bold size={15} />
98
+ </button>
99
+ <button type="button" onClick={() => wrapSelection('_')} className={toolbarButtonClass} title="Itálico (_texto_)" aria-label="Itálico">
100
+ <Italic size={15} />
101
+ </button>
102
+ <button type="button" onClick={() => wrapSelection('~')} className={toolbarButtonClass} title="Tachado (~texto~)" aria-label="Tachado">
103
+ <Strikethrough size={15} />
104
+ </button>
105
+ {placeholders.length > 0 && (
106
+ <>
107
+ <span className="mx-1 h-5 w-px bg-gray-200 dark:bg-gray-600" />
108
+ {placeholders.map((token) => (
109
+ <button
110
+ key={token}
111
+ type="button"
112
+ onClick={() => insertAtCursor(token)}
113
+ className="inline-flex items-center h-8 px-2 rounded-lg border border-gray-200 dark:border-gray-600 text-xs text-blue-600 dark:text-blue-300 hover:bg-blue-50 dark:hover:bg-blue-900/40 transition-colors"
114
+ title={`Inserir ${token}`}
115
+ >
116
+ {token}
117
+ </button>
118
+ ))}
119
+ </>
120
+ )}
121
+ </div>
122
+
123
+ <textarea
124
+ ref={textareaRef}
125
+ value={value}
126
+ onChange={(event) => onChange(event.target.value)}
127
+ placeholder={placeholder}
128
+ rows={rows}
129
+ className="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 focus:border-transparent transition-all placeholder:text-gray-400 dark:placeholder:text-gray-500 resize-y"
130
+ />
131
+
132
+ <div className="mt-2">
133
+ <p className="text-xs text-gray-500 dark:text-gray-400 mb-1">{previewLabel}</p>
134
+ <div className="rounded-xl bg-[#e5ded8] dark:bg-gray-900 p-3">
135
+ <div className="inline-block max-w-full rounded-lg rounded-tl-none bg-white dark:bg-gray-800 shadow-sm px-3 py-2 text-sm text-gray-800 dark:text-gray-100 whitespace-pre-wrap break-words">
136
+ {value.trim() ? renderWhatsApp(value) : <span className="text-gray-400 dark:text-gray-500">{emptyPreviewText}</span>}
137
+ </div>
138
+ </div>
139
+ </div>
140
+ </div>
141
+ )
142
+ }
@@ -0,0 +1 @@
1
+ export {}
@@ -0,0 +1 @@
1
+ export {}
@@ -0,0 +1,21 @@
1
+ import { type NodeProps } from '@xyflow/react'
2
+
3
+ export type FlowGroupFrameData = {
4
+ label: string
5
+ }
6
+
7
+ // Moldura decorativa atrás de uma cadeia de perguntas lineares que alimenta um nó de ação
8
+ // (ex.: "renda → valor do imóvel → prazo" antes de uma ação) — puramente visual, não editável,
9
+ // computada da topologia real do grafo (findCollectionChains), não é um dado novo.
10
+ export function FlowGroupFrame({ data }: NodeProps) {
11
+ const { label } = data as unknown as FlowGroupFrameData
12
+ return (
13
+ <div className="relative w-full h-full rounded-xl border-2 border-dashed border-orange-300/70 dark:border-orange-700/50 bg-orange-50/40 dark:bg-orange-950/10">
14
+ <span className="absolute -top-6 left-1 text-[11px] font-semibold text-orange-600 dark:text-orange-400 uppercase tracking-wide whitespace-nowrap">
15
+ {label}
16
+ </span>
17
+ </div>
18
+ )
19
+ }
20
+
21
+ export const flowGroupFrameNodeTypes = { flowGroupFrame: FlowGroupFrame }
@@ -0,0 +1,31 @@
1
+ import { type NodeProps } from '@xyflow/react'
2
+ import { Maximize2, X } from 'lucide-react'
3
+ import { DEFAULT_FLOW_EDITOR_LABELS, type FlowEditorLabels } from './labels'
4
+
5
+ export type FlowGroupHeaderData = {
6
+ label: string
7
+ labels?: FlowEditorLabels
8
+ onFocus: () => void
9
+ onClose: () => void
10
+ }
11
+
12
+ // Rótulo flutuante acima do primeiro nó de um fluxo mesclado no canvas (fusão editável) —
13
+ // não é um nó de verdade do grafo, só identifica de qual fluxo é aquele agrupado de cards e
14
+ // oferece as duas ações que o isolam de volta: focar nele sozinho, ou fechá-lo sem editar.
15
+ export function FlowGroupHeader({ data }: NodeProps) {
16
+ const { label, labels = DEFAULT_FLOW_EDITOR_LABELS, onFocus, onClose } = data as unknown as FlowGroupHeaderData
17
+
18
+ return (
19
+ <div className="flex items-center gap-2 rounded-full border border-cyan-300 dark:border-cyan-700 bg-cyan-50 dark:bg-cyan-950/50 px-3 py-1 text-xs font-medium text-cyan-800 dark:text-cyan-200 shadow-sm whitespace-nowrap">
20
+ <span>{label}</span>
21
+ <button type="button" onClick={onFocus} title={labels.flowGroup.focus} className="hover:text-blue-600 dark:hover:text-blue-400">
22
+ <Maximize2 size={12} />
23
+ </button>
24
+ <button type="button" onClick={onClose} title={labels.flowGroup.close} className="hover:text-red-600 dark:hover:text-red-400">
25
+ <X size={12} />
26
+ </button>
27
+ </div>
28
+ )
29
+ }
30
+
31
+ export const flowGroupHeaderNodeTypes = { flowGroupHeader: FlowGroupHeader }
@@ -0,0 +1,76 @@
1
+ import { useMemo } from 'react'
2
+ import { ReactFlow, Background, Controls, MarkerType, type Node, type Edge } from '@xyflow/react'
3
+ import '@xyflow/react/dist/style.css'
4
+ import { useDarkMode } from '../useDarkMode'
5
+ import { flowMapNodeTypes, type FlowMapNodeData } from './FlowMapNode'
6
+ import { computeFlowMapLayout, crossFlowTargetsOf, type FlowGraphData } from './flowGraph'
7
+ import { DEFAULT_FLOW_EDITOR_LABELS, type FlowEditorLabels } from './labels'
8
+
9
+ const MAP_NODE_TYPES = { ...flowMapNodeTypes }
10
+
11
+ const EDGE_COLOR = '#06b6d4'
12
+ const BACKGROUND_COLOR_LIGHT = '#cbd5e1'
13
+ const BACKGROUND_COLOR_DARK = '#334155'
14
+
15
+ export interface FlowMapCanvasProps {
16
+ graphs: Record<string, FlowGraphData>
17
+ rootKey: string
18
+ onOpenFlow: (key: string) => void
19
+ labels?: Partial<FlowEditorLabels>
20
+ }
21
+
22
+ // Paridade com financiamento-imobiliario-bot/apps/web/src/components/flows/FlowMapCanvas.tsx —
23
+ // visão hierárquica onde cada fluxo é um único nó, ligado por saltos "flow:<key>".
24
+ export function FlowMapCanvas({ graphs, rootKey, onOpenFlow, labels: labelsOverride }: FlowMapCanvasProps) {
25
+ const labels = { ...DEFAULT_FLOW_EDITOR_LABELS, ...labelsOverride }
26
+ const { isDark } = useDarkMode()
27
+ const positions = useMemo(() => computeFlowMapLayout(graphs, rootKey), [graphs, rootKey])
28
+
29
+ const nodes = useMemo<Node[]>(
30
+ () => Object.values(graphs).map((g): Node => ({
31
+ id: g.key,
32
+ type: 'flowMapNode',
33
+ position: positions[g.key] ?? { x: 0, y: 0 },
34
+ data: {
35
+ label: g.label,
36
+ nodeCount: Object.keys(g.nodes).length,
37
+ isRoot: g.key === rootKey,
38
+ labels,
39
+ onOpen: () => onOpenFlow(g.key),
40
+ } satisfies FlowMapNodeData,
41
+ })),
42
+ [graphs, positions, rootKey, onOpenFlow, labels],
43
+ )
44
+
45
+ const edges = useMemo<Edge[]>(() => {
46
+ const list: Edge[] = []
47
+ for (const g of Object.values(graphs)) {
48
+ for (const targetKey of crossFlowTargetsOf(g)) {
49
+ if (!graphs[targetKey]) continue
50
+ list.push({
51
+ id: `${g.key}->${targetKey}`,
52
+ source: g.key,
53
+ target: targetKey,
54
+ type: 'default',
55
+ style: { stroke: EDGE_COLOR, strokeWidth: 1.75 },
56
+ markerEnd: { type: MarkerType.ArrowClosed, color: EDGE_COLOR, width: 18, height: 18 },
57
+ })
58
+ }
59
+ }
60
+ return list
61
+ }, [graphs])
62
+
63
+ return (
64
+ <ReactFlow
65
+ nodes={nodes}
66
+ edges={edges}
67
+ nodeTypes={MAP_NODE_TYPES}
68
+ fitView
69
+ proOptions={{ hideAttribution: true }}
70
+ colorMode={isDark ? 'dark' : 'light'}
71
+ >
72
+ <Background color={isDark ? BACKGROUND_COLOR_DARK : BACKGROUND_COLOR_LIGHT} />
73
+ <Controls />
74
+ </ReactFlow>
75
+ )
76
+ }
@@ -0,0 +1,39 @@
1
+ import { Handle, Position, type NodeProps } from '@xyflow/react'
2
+ import { GitBranch, Maximize2 } from 'lucide-react'
3
+ import { DEFAULT_FLOW_EDITOR_LABELS, type FlowEditorLabels } from './labels'
4
+
5
+ export type FlowMapNodeData = {
6
+ label: string
7
+ nodeCount: number
8
+ isRoot: boolean
9
+ labels: FlowEditorLabels
10
+ onOpen: () => void
11
+ }
12
+
13
+ // Nó do MAPA de fluxos (visão hierárquica): representa um fluxo inteiro como uma única caixa,
14
+ // sem entrar nos nós internos — complementar à fusão editável, que mostra os nós de verdade.
15
+ export function FlowMapNode({ data }: NodeProps) {
16
+ const { label, nodeCount, isRoot, labels = DEFAULT_FLOW_EDITOR_LABELS, onOpen } = data as unknown as FlowMapNodeData
17
+
18
+ return (
19
+ <div className="relative rounded-xl border-2 border-emerald-300 dark:border-emerald-700 bg-emerald-50 dark:bg-emerald-950/40 px-4 py-3 w-56 shadow-sm">
20
+ <Handle type="target" position={Position.Top} className="!bg-gray-400 dark:!bg-gray-500" />
21
+ <div className="flex items-center gap-1.5 text-emerald-700 dark:text-emerald-300">
22
+ <GitBranch size={14} />
23
+ <span className="text-sm font-semibold truncate">{label}</span>
24
+ {isRoot && <span className="h-1.5 w-1.5 rounded-full bg-emerald-500 shrink-0" title={labels.startNodeTooltip} />}
25
+ </div>
26
+ <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">{labels.flowMap.nodeCount(nodeCount)}</p>
27
+ <button
28
+ type="button"
29
+ onClick={onOpen}
30
+ className="mt-2 inline-flex items-center gap-1 text-xs font-medium text-blue-600 dark:text-blue-400 hover:underline"
31
+ >
32
+ <Maximize2 size={11} /> {labels.flowMap.openFlow}
33
+ </button>
34
+ <Handle type="source" position={Position.Bottom} className="!bg-gray-400 dark:!bg-gray-500" />
35
+ </div>
36
+ )
37
+ }
38
+
39
+ export const flowMapNodeTypes = { flowMapNode: FlowMapNode }
@@ -0,0 +1,150 @@
1
+ import { Handle, Position, type NodeProps } from '@xyflow/react'
2
+ import { MessageCircleQuestion, GitBranch, Zap, ListTree, Diamond, AlertTriangle, AlertCircle, Headset, Clock3, ShoppingBag, type LucideIcon } from 'lucide-react'
3
+ import type { FlowEditorLabels } from './labels'
4
+ import type { FlowNodeData, GraphIssue } from './flowGraph'
5
+
6
+ const NODE_TYPE_COLOR: Record<FlowNodeData['type'], string> = {
7
+ question: 'border-blue-300 bg-blue-50 dark:bg-blue-950/40 dark:border-blue-800',
8
+ entrada_choice: 'border-purple-300 bg-purple-50 dark:bg-purple-950/40 dark:border-purple-800',
9
+ action: 'border-orange-300 bg-orange-50 dark:bg-orange-950/40 dark:border-orange-800',
10
+ menu: 'border-emerald-300 bg-emerald-50 dark:bg-emerald-950/40 dark:border-emerald-800',
11
+ condition: 'border-cyan-300 bg-cyan-50 dark:bg-cyan-950/40 dark:border-cyan-800',
12
+ }
13
+
14
+ const NODE_TYPE_ICON: Record<FlowNodeData['type'], LucideIcon> = {
15
+ question: MessageCircleQuestion,
16
+ entrada_choice: ListTree,
17
+ action: Zap,
18
+ menu: GitBranch,
19
+ condition: Diamond,
20
+ }
21
+
22
+ // Ícone por função da ação — o host estende via `actionKindIcons` (FlowNodeCardData) para
23
+ // registrar ícones dos próprios `actionKind`s sem o pacote assumir nenhum caso de negócio.
24
+ const DEFAULT_ACTION_KIND_ICON: Record<string, LucideIcon> = {
25
+ handoff: Headset,
26
+ rate_limited_handoff: Clock3,
27
+ send_product_list: ShoppingBag,
28
+ }
29
+
30
+ // Rótulo amigável para exibir em cartões/selects — nunca a chave interna crua (ex.: "action_handoff"),
31
+ // que só faz sentido para quem escreveu o interpretador do fluxo, não para quem está editando.
32
+ export function nodeLabel(node: FlowNodeData | undefined, labels: FlowEditorLabels): string {
33
+ if (!node) return '—'
34
+ if (node.type === 'action') {
35
+ const actionKindLabel = node.actionKind ? labels.actionKindLabels[node.actionKind] : undefined
36
+ // Sempre retorna string: um actionKind futuro sem label mapeado cai no próprio valor bruto
37
+ // em vez de undefined (que quebraria qualquer .length/truncate rio abaixo).
38
+ return node.directMessage || node.fallbackMessage || actionKindLabel || node.actionKind || node.id
39
+ }
40
+ if (node.type === 'condition') {
41
+ if (!node.conditionContextKey || !node.conditionOperator || !node.conditionValue) return node.id
42
+ const operatorLabel = labels.conditionOperatorLabels[node.conditionOperator] ?? node.conditionOperator
43
+ return `${node.conditionContextKey} ${operatorLabel} ${node.conditionValue}`
44
+ }
45
+ return node.question || node.contextKey || node.id
46
+ }
47
+
48
+ export type FlowNodeCardData = {
49
+ node: FlowNodeData
50
+ liveCount: number
51
+ isStart: boolean
52
+ isSelected: boolean
53
+ issues: GraphIssue[]
54
+ labels: FlowEditorLabels
55
+ actionKindIcons?: Record<string, LucideIcon>
56
+ onSelect: (id: string) => void
57
+ }
58
+
59
+ // Uma linha por saída: nós de escolha (question+choice ou menu) ganham UMA linha por opção mais
60
+ // uma linha "caso contrário" — cada uma com seu próprio handle, arrastável pra uma conexão
61
+ // condicional distinta. Nós lineares (pergunta simples) têm uma única linha "Próximo". Ações são
62
+ // terminais (o motor do host nunca continua a partir de 'next' de uma ação) — sem linha.
63
+ function sourceRows(node: FlowNodeData, labels: FlowEditorLabels): { id: string; label: string; isDefault: boolean }[] {
64
+ if (node.type === 'action') return []
65
+ if (node.type === 'condition') {
66
+ return [
67
+ { id: 'true', label: labels.nodePanel.conditionTrue, isDefault: false },
68
+ { id: 'false', label: labels.nodePanel.conditionFalse, isDefault: false },
69
+ ]
70
+ }
71
+ const isChoice = node.type === 'menu' || node.questionType === 'choice'
72
+ if (!isChoice) return [{ id: 'next', label: labels.nodePanel.nextRowLabel, isDefault: false }]
73
+ const options = node.options ?? []
74
+ return [
75
+ ...options.map(([id, label]) => ({ id, label, isDefault: false })),
76
+ { id: '__default', label: labels.edgeFallbackLabel, isDefault: true },
77
+ ]
78
+ }
79
+
80
+ // Uma linha de saída, com seu próprio handle ancorado na borda direita da própria linha (não
81
+ // mais distribuído na borda inferior do card) — assim dá pra ler "opção → destino" sem seguir
82
+ // o fio até o label da ligação, que é justamente o que confundia num fluxo com muitos ramos.
83
+ function SourceRow({ label, isDefault, handleId }: { label: string; isDefault: boolean; handleId: string }) {
84
+ return (
85
+ <div className="relative flex items-center gap-1.5 rounded-md border border-gray-200 dark:border-gray-600 bg-white/70 dark:bg-gray-900/40 px-2 py-1 pr-3">
86
+ <span className={`text-xs truncate flex-1 ${isDefault ? 'italic text-gray-400 dark:text-gray-500' : 'text-gray-700 dark:text-gray-200'}`}>
87
+ {label}
88
+ </span>
89
+ <Handle
90
+ type="source"
91
+ position={Position.Right}
92
+ id={handleId}
93
+ style={{ position: 'absolute', right: -7, top: '50%', transform: 'translateY(-50%)' }}
94
+ className={isDefault ? '!bg-gray-400 dark:!bg-gray-500' : '!bg-purple-500'}
95
+ />
96
+ </div>
97
+ )
98
+ }
99
+
100
+ export function FlowNodeCard({ data }: NodeProps) {
101
+ const { node, liveCount, isStart, isSelected, issues, labels, actionKindIcons, onSelect } = data as unknown as FlowNodeCardData
102
+ const label = nodeLabel(node, labels)
103
+ const iconMap = { ...DEFAULT_ACTION_KIND_ICON, ...actionKindIcons }
104
+ const Icon = node.type === 'action' && node.actionKind ? (iconMap[node.actionKind] ?? NODE_TYPE_ICON[node.type]) : NODE_TYPE_ICON[node.type]
105
+ const rows = sourceRows(node, labels)
106
+ const hasError = issues.some((i) => i.severity === 'error')
107
+ const hasWarning = !hasError && issues.some((i) => i.severity === 'warning')
108
+
109
+ return (
110
+ <div
111
+ title={label}
112
+ className={`relative rounded-lg border-2 px-3 py-2 w-60 cursor-pointer shadow-sm hover:shadow-md transition-shadow ${NODE_TYPE_COLOR[node.type]} ${isSelected ? 'ring-2 ring-blue-500 ring-offset-2 dark:ring-offset-gray-900' : ''}`}
113
+ onClick={() => onSelect(node.id)}
114
+ >
115
+ <Handle type="target" position={Position.Top} id="target" className="!bg-gray-400 dark:!bg-gray-500" />
116
+
117
+ <div className="flex items-center justify-between gap-2">
118
+ <span className="flex items-center gap-1.5 uppercase tracking-wide font-semibold text-gray-500 dark:text-gray-400 text-xs">
119
+ <Icon size={12} strokeWidth={2.5} />
120
+ {isStart && <span className="h-1.5 w-1.5 rounded-full bg-emerald-500" title={labels.startNodeTooltip} />}
121
+ {labels.legend[node.type]}
122
+ </span>
123
+ <div className="flex items-center gap-1">
124
+ {hasError && <AlertCircle size={13} className="text-red-600 dark:text-red-400" />}
125
+ {hasWarning && <AlertTriangle size={13} className="text-amber-500 dark:text-amber-400" />}
126
+ {liveCount > 0 && (
127
+ <span
128
+ title={labels.liveCountTooltip(liveCount)}
129
+ className="font-bold bg-blue-600 text-white rounded-full px-1.5 py-0.5 text-xs"
130
+ >
131
+ {liveCount}
132
+ </span>
133
+ )}
134
+ </div>
135
+ </div>
136
+
137
+ <p className="text-sm text-gray-900 dark:text-gray-100 mt-1 line-clamp-3">{label}</p>
138
+
139
+ {rows.length > 0 && (
140
+ <div className="mt-2 space-y-1">
141
+ {rows.map((row) => (
142
+ <SourceRow key={row.id} label={row.label} isDefault={row.isDefault} handleId={row.id} />
143
+ ))}
144
+ </div>
145
+ )}
146
+ </div>
147
+ )
148
+ }
149
+
150
+ export const flowNodeTypes = { flowNode: FlowNodeCard }