@adatechnology/conversations-ui 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/channel/index.d.ts +2 -0
- package/dist/channel/index.js +0 -0
- package/dist/chunk-ZDURDZTM.js +199 -0
- package/dist/flows/index.d.ts +246 -0
- package/dist/flows/index.js +1110 -0
- package/dist/index.d.ts +542 -0
- package/dist/index.js +2077 -0
- package/dist/styles.css +11 -0
- package/dist/styles.d.ts +2 -0
- package/package.json +52 -0
- package/src/AudioPlayer.tsx +103 -0
- package/src/Avatar.tsx +63 -0
- package/src/ConversationListItem.tsx +147 -0
- package/src/ConversationLocalesProvider.tsx +76 -0
- package/src/DateDivider.tsx +32 -0
- package/src/EmojiPicker.tsx +77 -0
- package/src/FileIcon.tsx +33 -0
- package/src/Lightbox.tsx +17 -0
- package/src/MediaRenderer.tsx +158 -0
- package/src/MessageBubble.tsx +129 -0
- package/src/MessageComposer.tsx +211 -0
- package/src/MessageTail.tsx +18 -0
- package/src/MessageText.tsx +42 -0
- package/src/MessageTimestamp.tsx +22 -0
- package/src/SimpleEmojiPicker.tsx +72 -0
- package/src/StatusTicks.tsx +39 -0
- package/src/Toast.tsx +140 -0
- package/src/Wallpaper.tsx +13 -0
- package/src/WhatsAppMessageEditor.tsx +142 -0
- package/src/channel/index.ts +1 -0
- package/src/conversations/index.ts +1 -0
- package/src/flows/FlowGroupFrame.tsx +21 -0
- package/src/flows/FlowGroupHeader.tsx +31 -0
- package/src/flows/FlowMapCanvas.tsx +76 -0
- package/src/flows/FlowMapNode.tsx +39 -0
- package/src/flows/FlowNodeCard.tsx +150 -0
- package/src/flows/FlowNodePanel.tsx +356 -0
- package/src/flows/FlowPalette.tsx +137 -0
- package/src/flows/FlowPortalNode.tsx +30 -0
- package/src/flows/FlowWhatsAppPreview.tsx +67 -0
- package/src/flows/flowGraph.ts +391 -0
- package/src/flows/index.ts +55 -0
- package/src/flows/labels.ts +187 -0
- package/src/hooks/useAsyncResource.ts +38 -0
- package/src/hooks/useConversationContext.ts +23 -0
- package/src/hooks/useConversationDocuments.ts +33 -0
- package/src/hooks/useConversationList.ts +32 -0
- package/src/hooks/useConversationMessages.ts +64 -0
- package/src/hooks/useConversationRealtime.ts +50 -0
- package/src/index.ts +87 -0
- package/src/lib/format.ts +32 -0
- package/src/lib/phone.ts +26 -0
- package/src/lib/whatsapp-formatting.tsx +215 -0
- package/src/providers/ConversationsProvider.tsx +29 -0
- package/src/providers/types.ts +54 -0
- package/src/settings/TopicsForm.tsx +109 -0
- package/src/settings/WelcomeFarewellForm.tsx +118 -0
- package/src/settings/WhatsAppCreateTemplateForm.tsx +309 -0
- package/src/settings/WhatsAppTemplateSettingsForm.tsx +264 -0
- package/src/styles.css +21 -0
- package/src/theme.ts +30 -0
- package/src/types.ts +44 -0
- package/src/useDarkMode.ts +48 -0
- package/src/useWaitingNotifications.ts +64 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,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 }
|