"use client"; import { useCallback, useState, useEffect, useRef } from "react"; import ReactFlow, { Node, Edge, addEdge, Connection, useNodesState, useEdgesState, Controls, MiniMap, Background, BackgroundVariant, NodeMouseHandler, SelectionMode, ReactFlowInstance, } from "reactflow"; import "reactflow/dist/style.css"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Plus, Save, Play, Copy, Trash2, Undo, Redo, FileText, Loader2, Download, Upload, HelpCircle, BookOpen, Hand, MousePointer2, Sparkles, TerminalIcon, X } from "lucide-react"; import { AiWorkflowDialog } from "./ai-workflow-dialog"; import { ImportWorkflowDialog } from "./import-workflow-dialog"; import { NodeConfigDialog } from "./node-config-dialog"; import { WorkflowNode } from "./workflow-node"; import { WorkflowTemplatesDialog } from "./workflow-templates-dialog"; import { WorkflowGuideDialog } from "./workflow-guide-dialog"; import { useToast } from "@/hooks/use-toast"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { useUndoRedo } from "./use-undo-redo"; const nodeTypes = { workflowNode: WorkflowNode, }; export interface NodeData { label: string; type: "start" | "condition" | "template" | "delay" | "custom" | "gemini" | "apiRequest" | "agent" | "webhook" | "schedule" | "merge" | "splitInBatches" | "filter" | "set" | "scraper" | "linkedinScraper" | "linkedinMessage" | "abSplit" | "whatsappNode" | "database" | "social_post" | "social_reply" | "social_monitor"; config?: { templateId?: string; delayHours?: number; // kept for legacy or generic delay condition?: string; customCode?: string; aiPrompt?: string; // API Request config url?: string; method?: "GET" | "POST" | "PUT" | "DELETE"; headers?: string; body?: string; // Agent config agentPrompt?: string; agentContext?: string; // New Nodes webhookMethod?: "GET" | "POST"; scheduleCron?: string; filterCondition?: string; setVariables?: Record; // Scraper config scraperAction?: "summarize" | "extract-emails" | "clean-html" | "markdown" | "fetch-url"; scraperInputField?: string; // Conditional Sending Config preventDuplicates?: boolean; cooldownDays?: number; // LinkedIn Config linkedinKeywords?: string; linkedinLocation?: string; profileUrl?: string; // used for message messageBody?: string; // A/B Split Config abSplitWeight?: number; // percentage for path A (default 50) // WhatsApp Config templateName?: string; variables?: string[]; // Database Config operation?: string; tableName?: string; data?: string; // Social Media Config platforms?: string[]; content?: string; mediaUrl?: string; accountId?: string; platform?: string; triggerType?: string; keywords?: string[]; responseTemplate?: string; actionType?: string; monitorType?: string; saveToVariable?: string; }; isConnected?: boolean; } interface NodeEditorProps { initialNodes?: Node[]; initialEdges?: Edge[]; onSave?: (nodes: Node[], edges: Edge[]) => void; isSaving?: boolean; workflowId?: string; } export function NodeEditor({ initialNodes = [], initialEdges = [], onSave, isSaving = false, workflowId, }: NodeEditorProps) { const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); // Update node connection status when edges change useEffect(() => { setNodes((nds) => nds.map((node) => { const isConnected = edges.some( (edge) => edge.source === node.id || edge.target === node.id ); return { ...node, data: { ...node.data, isConnected, }, }; }) ); }, [edges, setNodes]); const [selectedNode, setSelectedNode] = useState | null>(null); const [selectedNodes, setSelectedNodes] = useState[]>([]); const [isConfigOpen, setIsConfigOpen] = useState(false); const [isTemplatesOpen, setIsTemplatesOpen] = useState(false); const [isGuideOpen, setIsGuideOpen] = useState(false); const [isImportOpen, setIsImportOpen] = useState(false); const [isAiDialogOpen, setIsAiDialogOpen] = useState(false); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; nodeId?: string; edgeId?: string; } | null>(null); const [isExecuting, setIsExecuting] = useState(false); const [copiedNodes, setCopiedNodes] = useState[]>([]); const [executionLogs, setExecutionLogs] = useState([]); const [showTerminal, setShowTerminal] = useState(false); const [canvasMode, setCanvasMode] = useState<'drag' | 'select'>('drag'); const { toast } = useToast(); // Undo/Redo hook const { undo, redo, canUndo, canRedo, takeSnapshot } = useUndoRedo(initialNodes, initialEdges); // Undo Handler const handleUndo = useCallback(() => { const prevState = undo(nodes, edges); if (prevState) { setNodes(prevState.nodes); setEdges(prevState.edges); } }, [undo, nodes, edges, setNodes, setEdges]); // Redo Handler const handleRedo = useCallback(() => { const nextState = redo(nodes, edges); if (nextState) { setNodes(nextState.nodes); setEdges(nextState.edges); } }, [redo, nodes, edges, setNodes, setEdges]); // Keyboard Shortcuts useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { // Undo: Ctrl+Z if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) { e.preventDefault(); handleUndo(); } // Redo: Ctrl+Y or Ctrl+Shift+Z if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.shiftKey && e.key === 'z'))) { e.preventDefault(); handleRedo(); } // Save: Ctrl+S if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); if (onSave) { onSave(nodes, edges); toast({ title: "Saved", description: "Workflow saved successfully." }); } } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [handleUndo, handleRedo, onSave, nodes, edges, toast]); // Auto-Save Removed // const saveTimeoutRef = useRef(null); // const [isAutoSaving, setIsAutoSaving] = useState(false); // useEffect(() => { ... }) code removed // Paste node handler - using ref to avoid hoisting issues const handlePasteNodeRef = useRef<(() => void) | null>(null); const handlePasteNode = useCallback(() => { if (copiedNodes.length === 0) return; takeSnapshot(nodes, edges); // Snapshot before pasting const newNodes = copiedNodes.map((node) => ({ ...node, id: `${node.data.type}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, position: { x: node.position.x + 50, y: node.position.y + 50, }, selected: true, })); setNodes((nds) => [...nds.map(n => ({ ...n, selected: false })), ...newNodes]); setSelectedNodes(newNodes); }, [copiedNodes, setNodes, nodes, edges, takeSnapshot]); // Delete selected node handler - using ref to avoid hoisting issues const handleDeleteSelectedNodeRef = useRef<(() => void) | null>(null); const handleDeleteSelectedNode = useCallback(() => { if (selectedNodes.length > 0 || selectedNode) { takeSnapshot(nodes, edges); const idsToDelete = selectedNodes.length > 0 ? selectedNodes.map(n => n.id) : (selectedNode ? [selectedNode.id] : []); if (idsToDelete.length > 0) { setNodes((nds) => nds.filter((n) => !idsToDelete.includes(n.id))); setEdges((eds) => eds.filter((e) => !idsToDelete.includes(e.source) && !idsToDelete.includes(e.target)) ); setSelectedNodes([]); setSelectedNode(null); } } }, [selectedNode, selectedNodes, setNodes, setEdges, takeSnapshot, nodes, edges]); // Update refs in effect to avoid render side-effects useEffect(() => { handlePasteNodeRef.current = handlePasteNode; handleDeleteSelectedNodeRef.current = handleDeleteSelectedNode; }, [handlePasteNode, handleDeleteSelectedNode]); // Keyboard shortcuts useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { // Ctrl/Cmd + Z for undo if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) { e.preventDefault(); handleUndo(); } // Ctrl/Cmd + Shift + Z or Ctrl/Cmd + Y for redo if (((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'z') || ((e.ctrlKey || e.metaKey) && e.key === 'y')) { e.preventDefault(); handleRedo(); } // Ctrl/Cmd + C for copy if ((e.ctrlKey || e.metaKey) && e.key === 'c') { if (selectedNodes.length > 0) { e.preventDefault(); setCopiedNodes(selectedNodes); } else if (selectedNode) { e.preventDefault(); setCopiedNodes([selectedNode]); } } // Ctrl/Cmd + V for paste if ((e.ctrlKey || e.metaKey) && e.key === 'v' && copiedNodes.length > 0) { e.preventDefault(); handlePasteNodeRef.current?.(); } // Delete key to delete selected node if (e.key === 'Delete' && (selectedNode || selectedNodes.length > 0)) { e.preventDefault(); handleDeleteSelectedNodeRef.current?.(); } // Ctrl/Cmd + A for Select All if ((e.ctrlKey || e.metaKey) && e.key === 'a') { e.preventDefault(); setNodes((nds) => nds.map((n) => ({ ...n, selected: true }))); setSelectedNodes(nodes); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [selectedNode, selectedNodes, copiedNodes, handleUndo, handleRedo, nodes, setNodes]); const onConnect = useCallback( (params: Connection) => { takeSnapshot(nodes, edges); setEdges((eds) => addEdge(params, eds)); }, [setEdges, takeSnapshot, nodes, edges] ); const [rfInstance, setRfInstance] = useState(null); const addNode = useCallback( (type: NodeData["type"]) => { const nodeLabels = { start: "Start", condition: "Condition", template: "Send Email", delay: "Delay", custom: "Custom Function", gemini: "AI Task", apiRequest: "API Request", agent: "Agent with Excel", webhook: "Webhook", schedule: "Schedule", merge: "Merge", splitInBatches: "Loop", filter: "Filter", set: "Set Variables", scraper: "Scraper Action", linkedinScraper: "LinkedIn Scraper", linkedinMessage: "LinkedIn Message", abSplit: "A/B Split", whatsappNode: "Send WhatsApp", database: "Database Operation", social_post: "Social Post", social_reply: "Social Reply", social_monitor: "Social Monitor", }; // Calculate center position let position = { x: 250, y: 250 }; if (rfInstance) { const { x, y, zoom } = rfInstance.getViewport(); // Assuming a container width/height or using window as approximation if unknown, // but reactflow usually fills parent. // A safer bet is just center of the viewport - translation // Viewport x/y is the transformation. // Center X in flow = (-viewportX + containerHalfWidth) / zoom // We'll approximate container as 1000x800 if not easily accessible, or use window/2 const centerX = (-x + (window.innerWidth / 2)) / zoom; const centerY = (-y + (window.innerHeight / 2)) / zoom; position = { x: centerX - 100 + (Math.random() * 50), y: centerY - 50 + (Math.random() * 50) }; } const newNode: Node = { id: `${type}-${Date.now()}`, type: "workflowNode", data: { label: nodeLabels[type], type, config: {}, }, position, }; takeSnapshot(nodes, edges); setNodes((nds) => [...nds, newNode]); }, [setNodes, takeSnapshot, rfInstance, nodes, edges] ); const onNodeClick = useCallback((event: React.MouseEvent, node: Node) => { event.stopPropagation(); setSelectedNode(node); setIsConfigOpen(true); }, []); const onNodeContextMenu: NodeMouseHandler = useCallback((event, node) => { event.preventDefault(); setContextMenu({ x: event.clientX, y: event.clientY, nodeId: node.id, }); }, []); const onEdgeContextMenu = useCallback( (event: React.MouseEvent, edge: Edge) => { event.preventDefault(); setContextMenu({ x: event.clientX, y: event.clientY, edgeId: edge.id, }); }, [] ); const onPaneContextMenu = useCallback((event: React.MouseEvent) => { event.preventDefault(); setContextMenu({ x: event.clientX, y: event.clientY, nodeId: "", // Indicating pane }); }, []); const handleDeleteItem = useCallback(() => { if (!contextMenu) return; if (contextMenu.nodeId) { takeSnapshot(nodes, edges); setNodes((nds) => nds.filter((n) => n.id !== contextMenu.nodeId)); setEdges((eds) => eds.filter((e) => e.source !== contextMenu.nodeId && e.target !== contextMenu.nodeId) ); } else if (contextMenu.edgeId) { takeSnapshot(nodes, edges); setEdges((eds) => eds.filter((e) => e.id !== contextMenu.edgeId)); } setContextMenu(null); }, [contextMenu, setNodes, setEdges, takeSnapshot, nodes, edges]); const handleDuplicateNode = useCallback(() => { if (!contextMenu) return; const nodeToDuplicate = nodes.find((n) => n.id === contextMenu.nodeId); if (!nodeToDuplicate) return; const newNode: Node = { ...nodeToDuplicate, id: `${nodeToDuplicate.data.type}-${Date.now()}`, position: { x: nodeToDuplicate.position.x + 50, y: nodeToDuplicate.position.y + 50, }, }; takeSnapshot(nodes, edges); setNodes((nds) => [...nds, newNode]); setContextMenu(null); }, [contextMenu, nodes, setNodes, takeSnapshot, edges]); const updateNodeConfig = useCallback( (nodeId: string, config: NodeData["config"], label?: string) => { takeSnapshot(nodes, edges); setNodes((nds) => nds.map((node) => node.id === nodeId ? { ...node, data: { ...node.data, config, ...(label ? { label } : {}) } } : node ) ); }, [setNodes, takeSnapshot, nodes, edges] ); const handleSave = useCallback(() => { if (onSave) { onSave(nodes, edges); } else { toast({ title: "Success", description: "Workflow saved locally!", }); } }, [nodes, edges, onSave, toast]); const executeWorkflow = useCallback(async () => { if (!workflowId) { toast({ title: "Error", description: "Please save the workflow before running it.", variant: "destructive", }); return; } const startNode = nodes.find((n) => n.data.type === "start"); if (!startNode) { toast({ title: "Error", description: "No start node found! Please add a Start node to begin the workflow.", variant: "destructive", }); return; } setIsExecuting(true); toast({ title: "Starting Execution", description: "Running workflow on pending businesses..." }); try { const response = await fetch("/api/workflows/execute", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ workflowId }), }); const data = await response.json(); setExecutionLogs(data.logs || []); setShowTerminal(true); if (data.success) { toast({ title: "Execution Completed", description: `Processed ${data.totalProcessed} businesses. Check terminal for details.`, }); } else { toast({ title: "Execution Failed", description: "Check terminal for logs.", variant: "destructive", }); } } catch (error) { toast({ title: "Error", description: "Failed to execute workflow", variant: "destructive", }); console.error(error); } finally { setIsExecuting(false); } }, [workflowId, nodes, toast]); const loadTemplate = useCallback((templateNodes: Node[], templateEdges: Edge[]) => { takeSnapshot(nodes, edges); setNodes(templateNodes); setEdges(templateEdges); setIsTemplatesOpen(false); }, [setNodes, setEdges, takeSnapshot, nodes, edges]); const handleAiGenerate = useCallback((generatedNodes: Node[], generatedEdges: Edge[]) => { takeSnapshot(nodes, edges); setNodes(generatedNodes); setEdges(generatedEdges); toast({ title: "Workflow Generated", description: "AI successfully created the workflow structure.", }); }, [setNodes, setEdges, takeSnapshot, toast, nodes, edges]); // Close context menu when clicking outside useEffect(() => { const handleClick = () => setContextMenu(null); if (contextMenu) { document.addEventListener("click", handleClick); return () => document.removeEventListener("click", handleClick); } }, [contextMenu]); return (
{/* Toolbar */} Workflow Editor
Generate workflow with AI Terminal
Undo (Ctrl+Z) Redo (Ctrl+Shift+Z)
Pan Mode (Drag to move canvas)
Select Mode (Drag to select nodes)
Workflow Guide Templates
Save Workflow {isExecuting ? "Running..." : "Test Run"}
{/* Export / Import */} Export JSON Import JSON
{/* Mobile: Scrollable toolbar */}
{/* Bulk Actions Menu if multiple selected */} {selectedNodes.length > 1 && (
{selectedNodes.length} nodes selected
)} {/* Button Groups for better organization */}
Triggers: {["start", "webhook", "schedule"].map((type) => ( ))}
Actions: {["template", "apiRequest", "gemini", "agent", "set", "scraper", "whatsappNode"].map((type) => ( Add {type} node ))}
Logic: {["condition", "delay", "merge", "splitInBatches", "filter", "custom", "abSplit"].map((type) => ( Add {type} node ))}

Shortcuts: Ctrl+C/V (copy/paste), Ctrl+Z/Y (undo/redo), Delete (remove node)

{/* React Flow Canvas Canvas - Adjusted for mobile height */}
setSelectedNodes(nodes)} >
Canvas
{/* Context Menu */} {contextMenu && (
{contextMenu.nodeId ? ( <> ) : contextMenu.edgeId ? ( ) : ( <> )}
)}
{/* Node Configuration Dialog */} {selectedNode && ( { updateNodeConfig(selectedNode.id, config, label); setIsConfigOpen(false); }} onDelete={() => { takeSnapshot(nodes, edges); setNodes((nds) => nds.filter((n) => n.id !== selectedNode.id)); setEdges((eds) => eds.filter((e) => e.source !== selectedNode.id && e.target !== selectedNode.id)); setSelectedNode(null); setIsConfigOpen(false); }} /> )} {/* Workflow Templates Dialog */} { takeSnapshot(nodes, edges); setNodes(n); setEdges(e); setIsImportOpen(false); }} /> {/* Terminal Panel */} {showTerminal && (
SYSTEM TERMINAL {executionLogs.length} Lines
{executionLogs.length === 0 ? (

Waiting for execution...

) : ( executionLogs.map((log, i) => (
{(i + 1).toString().padStart(2, '0')} {log}
)) )}
)}
); }