226 lines
10 KiB
TypeScript
226 lines
10 KiB
TypeScript
import React, { useCallback, useRef } from 'react';
|
|
import {
|
|
ReactFlow,
|
|
MiniMap,
|
|
Controls,
|
|
Background,
|
|
useNodesState,
|
|
useEdgesState,
|
|
useReactFlow,
|
|
addEdge,
|
|
Connection,
|
|
ReactFlowProvider,
|
|
Node,
|
|
Edge,
|
|
} from '@xyflow/react';
|
|
import '@xyflow/react/dist/style.css';
|
|
import { ArrowLeft, Save, Play, MessageSquare, Zap, Clock, Image as ImageIcon } from 'lucide-react';
|
|
import Link from 'next/link';
|
|
|
|
// --- Custom Nodes Types Implementation ---
|
|
import MessageNode from './_components/MessageNode';
|
|
import TriggerNode from './_components/TriggerNode';
|
|
|
|
const nodeTypes = {
|
|
messageNode: MessageNode,
|
|
triggerNode: TriggerNode,
|
|
};
|
|
|
|
// Initial state for the canvas
|
|
const initialNodes: Node[] = [
|
|
{
|
|
id: 'start-trigger',
|
|
type: 'triggerNode',
|
|
position: { x: 250, y: 150 },
|
|
data: { label: 'Keyword Trigger', keyword: 'hello' },
|
|
},
|
|
];
|
|
|
|
const initialEdges: Edge[] = [];
|
|
|
|
function FlowBuilder() {
|
|
const { screenToFlowPosition } = useReactFlow();
|
|
// Contador via ref: persiste entre re-renders sem resetar no HMR
|
|
const nodeIdRef = useRef(0);
|
|
const getId = useCallback(() => `node-${nodeIdRef.current++}`, []);
|
|
|
|
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
|
|
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
|
|
|
|
const onConnect = useCallback((params: Connection) => {
|
|
setEdges((eds) => addEdge({ ...params, animated: true }, eds));
|
|
}, [setEdges]);
|
|
|
|
const onDragOver = useCallback((event: React.DragEvent) => {
|
|
event.preventDefault();
|
|
event.dataTransfer.dropEffect = 'move';
|
|
}, []);
|
|
|
|
const onDrop = useCallback(
|
|
(event: React.DragEvent) => {
|
|
event.preventDefault();
|
|
|
|
const type = event.dataTransfer.getData('application/reactflow');
|
|
if (!type) return;
|
|
|
|
// screenToFlowPosition em @xyflow/react v12 recebe coordenadas de tela
|
|
// (clientX/Y) e desconta internamente a posição do container.
|
|
// NÃO subtrair getBoundingClientRect() — isso duplicaria o offset.
|
|
const position = screenToFlowPosition({
|
|
x: event.clientX,
|
|
y: event.clientY,
|
|
});
|
|
|
|
const newNode: Node = {
|
|
id: getId(),
|
|
type,
|
|
position,
|
|
data: { label: `${type} node` },
|
|
};
|
|
|
|
setNodes((nds) => nds.concat(newNode));
|
|
},
|
|
[screenToFlowPosition, getId, setNodes]
|
|
);
|
|
|
|
const onDragStart = (event: React.DragEvent, nodeType: string) => {
|
|
event.dataTransfer.setData('application/reactflow', nodeType);
|
|
event.dataTransfer.effectAllowed = 'move';
|
|
};
|
|
|
|
return (
|
|
<div className="h-screen w-full flex flex-col bg-[#070b14] overflow-hidden">
|
|
{/* Header */}
|
|
<header className="h-16 px-6 border-b border-white/5 bg-white/[0.02] flex items-center justify-between z-10">
|
|
<div className="flex items-center gap-4">
|
|
<Link href="/dashboard" className="p-2 -ml-2 rounded-lg text-slate-400 hover:text-white hover:bg-white/5 transition-all">
|
|
<ArrowLeft size={18} />
|
|
</Link>
|
|
<div className="h-8 w-[1px] bg-white/10" />
|
|
<div>
|
|
<h1 className="text-white font-bold text-sm">Main Customer Flow</h1>
|
|
<p className="text-xs text-brand-400">Status: Draft</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3">
|
|
<button className="flex items-center gap-2 px-4 py-2 rounded-lg bg-white/5 text-white text-sm font-semibold hover:bg-white/10 transition-all border border-white/10">
|
|
<Play size={14} className="text-slate-300" />
|
|
Test Flow
|
|
</button>
|
|
<button className="flex items-center gap-2 px-4 py-2 rounded-lg bg-brand-500 text-white text-sm font-bold shadow-lg shadow-brand-500/20 hover:bg-brand-600 transition-all">
|
|
<Save size={14} />
|
|
Publish Builder
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
<div className="flex-1 flex w-full h-full relative">
|
|
{/* Sidebar Toolbar */}
|
|
<aside className="w-72 bg-white/[0.02] border-r border-white/5 p-4 flex flex-col gap-6 z-10 overflow-y-auto">
|
|
<div>
|
|
<h3 className="text-xs font-bold text-slate-500 uppercase tracking-widest mb-4 ml-1">Triggers</h3>
|
|
<div className="space-y-2">
|
|
<div
|
|
onDragStart={(event) => onDragStart(event, 'triggerNode')}
|
|
draggable
|
|
className="p-3 bg-white/5 border border-white/10 rounded-xl cursor-grab hover:bg-white/10 hover:border-brand-500/50 transition-all flex items-center gap-3 group"
|
|
>
|
|
<div className="w-8 h-8 rounded-lg bg-brand-500/20 flex items-center justify-center">
|
|
<Zap size={16} className="text-brand-400" />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-medium text-white group-hover:text-brand-400 transition-colors">Keyword</p>
|
|
<p className="text-[11px] text-slate-400">Trigger on exact match</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<h3 className="text-xs font-bold text-slate-500 uppercase tracking-widest mb-4 ml-1">Messages</h3>
|
|
<div className="space-y-2">
|
|
<div
|
|
onDragStart={(event) => onDragStart(event, 'messageNode')}
|
|
draggable
|
|
className="p-3 bg-white/5 border border-white/10 rounded-xl cursor-grab hover:bg-white/10 hover:border-blue-500/50 transition-all flex items-center gap-3 group"
|
|
>
|
|
<div className="w-8 h-8 rounded-lg bg-blue-500/20 flex items-center justify-center">
|
|
<MessageSquare size={16} className="text-blue-400" />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-medium text-white group-hover:text-blue-400 transition-colors">Text Message</p>
|
|
<p className="text-[11px] text-slate-400">Send standard WhatsApp text</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="p-3 bg-white/5 border border-white/10 rounded-xl cursor-not-allowed opacity-50 flex items-center gap-3">
|
|
<div className="w-8 h-8 rounded-lg bg-purple-500/20 flex items-center justify-center">
|
|
<ImageIcon size={16} className="text-purple-400" />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-medium text-white">Media/Doc</p>
|
|
<p className="text-[11px] text-slate-400">Coming soon</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<h3 className="text-xs font-bold text-slate-500 uppercase tracking-widest mb-4 ml-1">Actions</h3>
|
|
<div className="space-y-2">
|
|
<div className="p-3 bg-white/5 border border-white/10 rounded-xl cursor-not-allowed opacity-50 flex items-center gap-3">
|
|
<div className="w-8 h-8 rounded-lg bg-orange-500/20 flex items-center justify-center">
|
|
<Clock size={16} className="text-orange-400" />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-medium text-white">Delay</p>
|
|
<p className="text-[11px] text-slate-400">Wait X seconds</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
|
|
{/* ReactFlow Canvas */}
|
|
<div className="flex-1 h-full">
|
|
<ReactFlow
|
|
nodes={nodes}
|
|
edges={edges}
|
|
onNodesChange={onNodesChange}
|
|
onEdgesChange={onEdgesChange}
|
|
onConnect={onConnect}
|
|
onDrop={onDrop}
|
|
onDragOver={onDragOver}
|
|
nodeTypes={nodeTypes}
|
|
fitView
|
|
className="bg-[#04060b]"
|
|
minZoom={0.2}
|
|
maxZoom={2}
|
|
>
|
|
<Background color="#ffffff" gap={20} size={1} />
|
|
<Controls className="bg-white/5 border-white/10 fill-white !text-white" />
|
|
<MiniMap
|
|
nodeColor={(n: any) => {
|
|
if (n.type === 'triggerNode') return '#10b981';
|
|
return '#3b82f6';
|
|
}}
|
|
maskColor="rgba(0,0,0,0.7)"
|
|
className="bg-black/50 border border-white/10 rounded-xl !w-40"
|
|
/>
|
|
</ReactFlow>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Wrap with Provider
|
|
export default function FlowBuilderPage() {
|
|
return (
|
|
<ReactFlowProvider>
|
|
<FlowBuilder />
|
|
</ReactFlowProvider>
|
|
);
|
|
}
|