diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b5acf7c..c4bb976 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -71,3 +71,4 @@ jobs: + diff --git a/.npmrc b/.npmrc index 28824a4..a5feec6 100644 --- a/.npmrc +++ b/.npmrc @@ -14,3 +14,4 @@ + diff --git a/examples/next-js/app/builder/layout.tsx b/examples/next-js/app/builder/layout.tsx new file mode 100644 index 0000000..c48eec7 --- /dev/null +++ b/examples/next-js/app/builder/layout.tsx @@ -0,0 +1,8 @@ +export default function BuilderLayout({ + children, +}: { + children: React.ReactNode; +}) { + return <>{children}; +} + diff --git a/examples/next-js/app/builder/page.tsx b/examples/next-js/app/builder/page.tsx new file mode 100644 index 0000000..a174d11 --- /dev/null +++ b/examples/next-js/app/builder/page.tsx @@ -0,0 +1,97 @@ +'use client'; + +import { useMemo } from 'react'; +import { ReactFlowProvider } from '@xyflow/react'; +import { useGillTransactionSigner, useConnectorClient } from '@solana/connector'; +import { createSolanaRpc, createSolanaRpcSubscriptions, address } from '@solana/kit'; + +import { + BuilderCanvas, + BuilderToolbar, + NodePalette, + NodeInspector, + FeedbackPanel, +} from '@/components/builder'; +import { useBuilderFeedback } from '@/lib/builder'; +import type { CompileContext } from '@/lib/builder/types'; +import { ConnectButton } from '@/components/connector'; + +// ============================================================================= +// Builder Page Content +// ============================================================================= + +function BuilderContent() { + const { signer, ready } = useGillTransactionSigner(); + const client = useConnectorClient(); + + // Create compile context from wallet connection + const compileContext = useMemo(() => { + if (!ready || !signer || !client) return null; + + const rpcUrl = client.getRpcUrl(); + if (!rpcUrl) return null; + + const rpc = createSolanaRpc(rpcUrl); + const rpcSubscriptions = createSolanaRpcSubscriptions( + rpcUrl.replace('https', 'wss').replace('http', 'ws') + ); + + return { + signer, + rpc, + rpcSubscriptions, + walletAddress: address(signer.address), + }; + }, [ready, signer, client]); + + // Get feedback for the current graph + const feedback = useBuilderFeedback(compileContext); + + return ( +
+ {/* Toolbar */} + + + {/* Main content */} +
+ {/* Node palette */} + + + {/* Canvas */} +
+ {/* Connection status banner */} + {!ready && ( +
+ + Connect your wallet to simulate and execute transactions + + +
+ )} + + {/* React Flow canvas */} + + + {/* Feedback panel */} + +
+ + {/* Inspector panel */} + +
+
+ ); +} + +// ============================================================================= +// Builder Page +// ============================================================================= + +export default function BuilderPage() { + return ( + + + + ); +} + diff --git a/examples/next-js/components/builder/builder-canvas.tsx b/examples/next-js/components/builder/builder-canvas.tsx new file mode 100644 index 0000000..67ad083 --- /dev/null +++ b/examples/next-js/components/builder/builder-canvas.tsx @@ -0,0 +1,262 @@ +'use client'; + +import { useCallback, useRef, useMemo } from 'react'; +import { + ReactFlow, + ReactFlowProvider, + Background, + Controls, + Panel, + addEdge, + useReactFlow, + useViewport, + type Connection, + type ReactFlowInstance, + BackgroundVariant, + ConnectionLineType, +} from '@xyflow/react'; +import '@xyflow/react/dist/style.css'; + +import { useBuilderStore, useBatchGroups } from '@/lib/builder/store'; +import { nodeTypes } from './nodes'; +import type { NodeType, BuilderNode } from '@/lib/builder/types'; +import { Plus } from 'lucide-react'; + +// ============================================================================= +// Batch Group Overlay Component +// ============================================================================= + +interface BatchGroupOverlayProps { + nodes: BuilderNode[]; +} + +/** + * Renders dotted-border overlays around batch groups. + * Must be rendered inside ReactFlow context to access viewport. + */ +function BatchGroupOverlay({ nodes }: BatchGroupOverlayProps) { + const batchGroups = useBatchGroups(); + const viewport = useViewport(); + + if (batchGroups.length === 0) { + return null; + } + + return ( + +
+ {batchGroups.map(group => { + // Find all nodes in this batch group + const groupNodes = nodes.filter(n => group.nodeIds.includes(n.id)); + if (groupNodes.length < 2) return null; + + // Calculate bounding box with padding + const padding = 12; + const nodeWidth = 140; // Approximate node width + const nodeHeight = 70; // Approximate node height + + let minX = Infinity, minY = Infinity; + let maxX = -Infinity, maxY = -Infinity; + + for (const node of groupNodes) { + minX = Math.min(minX, node.position.x); + minY = Math.min(minY, node.position.y); + maxX = Math.max(maxX, node.position.x + nodeWidth); + maxY = Math.max(maxY, node.position.y + nodeHeight); + } + + // Transform to screen coordinates + const screenX = (minX - padding) * viewport.zoom + viewport.x; + const screenY = (minY - padding) * viewport.zoom + viewport.y; + const screenWidth = (maxX - minX + padding * 2) * viewport.zoom; + const screenHeight = (maxY - minY + padding * 2) * viewport.zoom; + + return ( +
+ {/* Batch label */} +
+ Batch +
+
+ ); + })} +
+
+ ); +} + +// ============================================================================= +// Builder Canvas Component +// ============================================================================= + +export function BuilderCanvas() { + const reactFlowWrapper = useRef(null); + const reactFlowInstance = useRef | null>(null); + + // Store state and actions + const nodes = useBuilderStore(state => state.nodes); + const edges = useBuilderStore(state => state.edges); + const addNode = useBuilderStore(state => state.addNode); + const setEdges = useBuilderStore(state => state.setEdges); + const onNodesChange = useBuilderStore(state => state.onNodesChange); + const onEdgesChange = useBuilderStore(state => state.onEdgesChange); + const selectNode = useBuilderStore(state => state.selectNode); + + // Handle new connections - style based on handle type + const onConnect = useCallback( + (connection: Connection) => { + // Determine if this is a horizontal (batch) connection + const isBatchConnection = + connection.sourceHandle?.includes('batch') || + connection.targetHandle?.includes('batch'); + + // Add appropriate styling based on connection type + const styledConnection = { + ...connection, + ...(isBatchConnection ? { + // Horizontal batch edge styling + style: { stroke: '#f59e0b', strokeWidth: 2, strokeDasharray: '5,5' }, + animated: true, + type: 'smoothstep', + } : { + // Vertical sequential edge styling + style: { stroke: '#94a3b8', strokeWidth: 2 }, + animated: true, + type: 'smoothstep', + }), + }; + + setEdges(addEdge(styledConnection, edges)); + }, + [edges, setEdges] + ); + + // Handle node selection + const onNodeClick = useCallback( + (_: React.MouseEvent, node: any) => { + selectNode(node.id); + }, + [selectNode] + ); + + // Handle canvas click (deselect) + const onPaneClick = useCallback(() => { + selectNode(null); + }, [selectNode]); + + // Handle drag over (for palette drag-drop) + const onDragOver = useCallback((event: React.DragEvent) => { + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + }, []); + + // Handle drop from palette + const onDrop = useCallback( + (event: React.DragEvent) => { + event.preventDefault(); + + const type = event.dataTransfer.getData('application/reactflow') as NodeType; + if (!type) return; + + // Get position relative to the canvas + const reactFlowBounds = reactFlowWrapper.current?.getBoundingClientRect(); + if (!reactFlowBounds || !reactFlowInstance.current) return; + + const position = reactFlowInstance.current.screenToFlowPosition({ + x: event.clientX - reactFlowBounds.left, + y: event.clientY - reactFlowBounds.top, + }); + + addNode(type, position); + }, + [addNode] + ); + + // Store React Flow instance + const onInit = useCallback((instance: ReactFlowInstance) => { + reactFlowInstance.current = instance; + }, []); + + // Check if only default nodes exist (wallet + execute, no instruction nodes) + const onlyDefaultNodes = useMemo(() => { + const instructionNodes = nodes.filter( + n => n.type !== 'wallet' && n.type !== 'execute' + ); + return instructionNodes.length === 0; + }, [nodes]); + + return ( +
+ + + + + {/* Batch group overlays */} + + + + {/* Empty state hint - shown when only default nodes exist */} + {onlyDefaultNodes && ( +
+
+ +

Drag instruction nodes here

+

e.g. Transfer SOL, Memo, Token Transfer

+
+
+ )} +
+ ); +} + diff --git a/examples/next-js/components/builder/builder-toolbar.tsx b/examples/next-js/components/builder/builder-toolbar.tsx new file mode 100644 index 0000000..b31b42a --- /dev/null +++ b/examples/next-js/components/builder/builder-toolbar.tsx @@ -0,0 +1,547 @@ +'use client'; + +import { useCallback, useState, useMemo } from 'react'; +import { useCluster } from '@solana/connector'; +import { useBuilderStore, useExecutionState } from '@/lib/builder/store'; +import { compileGraph, extractExecutionConfig } from '@/lib/builder/compiler'; +import { TransactionBuilder, JITO_BLOCK_ENGINES } from '@pipeit/core'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { cn } from '@/lib/utils'; +import { + Play, + FlaskConical, + Trash2, + CheckCircle, + XCircle, + Loader2, + AlertCircle, + Rocket, + Zap, + Shield, + ChevronLeft, +} from 'lucide-react'; +import Link from 'next/link'; +import type { CompileContext, JitoRegion } from '@/lib/builder/types'; + +// ============================================================================= +// Props +// ============================================================================= + +interface BuilderToolbarProps { + compileContext: CompileContext | null; + onSimulate?: () => void; +} + +// ============================================================================= +// Toolbar Component +// ============================================================================= + +export function BuilderToolbar({ compileContext, onSimulate }: BuilderToolbarProps) { + const nodes = useBuilderStore(state => state.nodes); + const edges = useBuilderStore(state => state.edges); + const config = useBuilderStore(state => state.config); + const executionState = useExecutionState(); + const setExecutionState = useBuilderStore(state => state.setExecutionState); + const reset = useBuilderStore(state => state.reset); + const { cluster } = useCluster(); + + const [isSimulating, setIsSimulating] = useState(false); + const [simulationResult, setSimulationResult] = useState<{ + success: boolean; + computeUnits?: number; + solTransfer?: string; // SOL amount as formatted string + tokenTransfers?: Array<{ amount: string; symbol: string }>; // Token transfers + error?: string; + } | null>(null); + + // Count instruction nodes (not wallet or execute - those are structural) + const instructionNodeCount = nodes.filter( + n => n.type !== 'wallet' && n.type !== 'execute' + ).length; + + const canExecute = instructionNodeCount > 0 && compileContext !== null; + const isExecuting = executionState.status !== 'idle' && + executionState.status !== 'success' && + executionState.status !== 'error'; + + // Extract execution config from Execute node (if present) + const executionConfig = useMemo(() => extractExecutionConfig(nodes), [nodes]); + + // Build explorer URL based on cluster + const getExplorerUrl = useMemo(() => { + return (signature: string) => { + const clusterId = cluster?.id || 'solana:mainnet'; + // Solscan uses different URL patterns + if (clusterId === 'solana:mainnet' || clusterId.includes('mainnet')) { + return `https://solscan.io/tx/${signature}`; + } else if (clusterId === 'solana:devnet' || clusterId.includes('devnet')) { + return `https://solscan.io/tx/${signature}?cluster=devnet`; + } else if (clusterId === 'solana:testnet' || clusterId.includes('testnet')) { + return `https://solscan.io/tx/${signature}?cluster=testnet`; + } + // Fallback to Solana Explorer for other clusters + return `https://explorer.solana.com/tx/${signature}?cluster=${clusterId.replace('solana:', '')}`; + }; + }, [cluster]); + + // Handle simulation + const handleSimulate = useCallback(async () => { + if (!compileContext) { + console.log('[Builder] No compile context - wallet not connected'); + return; + } + + console.log('[Builder] Starting simulation with', nodes.length, 'nodes'); + setIsSimulating(true); + setSimulationResult(null); + + try { + const compiled = await compileGraph(nodes, edges, compileContext); + console.log('[Builder] Compiled', compiled.instructions.length, 'instructions'); + + if (compiled.instructions.length === 0) { + setSimulationResult({ + success: false, + error: 'No instructions to simulate. Fill in the node configuration fields.', + }); + setIsSimulating(false); + return; + } + + const builder = new TransactionBuilder({ + rpc: compileContext.rpc, + priorityFee: config.priorityFee, + computeUnits: config.computeUnits === 'auto' ? undefined : config.computeUnits, + }) + .setFeePayerSigner(compileContext.signer) + .addInstructions(compiled.instructions); + + console.log('[Builder] Running simulation (will prompt wallet for signature)...'); + const result = await builder.simulate(); + console.log('[Builder] Simulation result:', result); + + // Format SOL transfer amount if present + let solTransfer: string | undefined; + if (compiled.totalSolTransferLamports) { + const solAmount = Number(compiled.totalSolTransferLamports) / 1_000_000_000; + solTransfer = solAmount.toLocaleString(undefined, { + minimumFractionDigits: 0, + maximumFractionDigits: 9 + }) + ' SOL'; + } + + // Format token transfers if present + let tokenTransfers: Array<{ amount: string; symbol: string }> | undefined; + if (compiled.tokenTransfers && compiled.tokenTransfers.length > 0) { + // Known token symbols + const KNOWN_TOKENS: Record = { + 'So11111111111111111111111111111111111111112': 'SOL', + 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v': 'USDC', + 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB': 'USDT', + }; + + tokenTransfers = compiled.tokenTransfers.map(transfer => { + const displayAmount = Number(transfer.amount) / Math.pow(10, transfer.decimals); + // Use known symbol or truncate mint for display + const symbol = KNOWN_TOKENS[transfer.mint] + ?? (transfer.mint.length > 10 + ? `${transfer.mint.slice(0, 4)}...${transfer.mint.slice(-4)}` + : transfer.mint); + return { + amount: displayAmount.toLocaleString(undefined, { + minimumFractionDigits: 0, + maximumFractionDigits: Math.min(transfer.decimals, 6), // Cap at 6 decimals for readability + }), + symbol, + }; + }); + } + + setSimulationResult({ + success: result.err === null, + computeUnits: result.unitsConsumed ? Number(result.unitsConsumed) : undefined, + solTransfer, + tokenTransfers, + error: result.err ? JSON.stringify(result.err) : undefined, + }); + } catch (error) { + console.error('[Builder] Simulation error:', error); + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + + // Provide more helpful error messages + let displayError = errorMessage; + if (errorMessage.includes('Failed to sign')) { + displayError = 'Simulation requires wallet approval. Please approve in your wallet, or click Execute to run directly.'; + } else if (errorMessage.includes('User rejected')) { + displayError = 'Wallet signature rejected. Simulation cancelled.'; + } + + setSimulationResult({ + success: false, + error: displayError, + }); + } finally { + setIsSimulating(false); + } + }, [nodes, edges, compileContext, config]); + + // Submit transaction via TPU API route + const submitViaTpu = async (base64Tx: string): Promise<{ delivered: boolean; latencyMs: number }> => { + console.log('[Builder] Submitting via TPU API...'); + const response = await fetch('/api/tpu', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ transaction: base64Tx }), + }); + + const result = await response.json(); + console.log('[Builder] TPU result:', result); + + if (!response.ok || result.error) { + throw new Error(result.error || 'TPU submission failed'); + } + + return result; + }; + + // Handle execution + const handleExecute = useCallback(async () => { + if (!compileContext) { + console.log('[Builder] No compile context - wallet not connected'); + return; + } + + console.log('[Builder] Starting execution with', nodes.length, 'nodes'); + console.log('[Builder] Execution strategy:', executionConfig.strategy); + setExecutionState({ status: 'compiling' }); + setSimulationResult(null); + + try { + const compiled = await compileGraph(nodes, edges, compileContext); + console.log('[Builder] Compiled', compiled.instructions.length, 'instructions'); + + if (compiled.instructions.length === 0) { + setExecutionState({ + status: 'error', + error: new Error('No instructions to execute. Fill in the node configuration fields.'), + }); + return; + } + + setExecutionState({ status: 'signing' }); + console.log('[Builder] Building and signing transaction...'); + + // Build execution config based on Execute node settings + const builderConfig = { + rpc: compileContext.rpc, + priorityFee: config.priorityFee, + computeUnits: config.computeUnits === 'auto' ? 200_000 : config.computeUnits, + autoRetry: { maxAttempts: 3, backoff: 'exponential' as const }, + logLevel: 'verbose' as const, + }; + + const builder = new TransactionBuilder(builderConfig) + .setFeePayerSigner(compileContext.signer) + .addInstructions(compiled.instructions); + + // For ultra strategy: use TPU direct submission via native QUIC + if (executionConfig.strategy === 'ultra' && executionConfig.tpu?.enabled) { + console.log('[Builder] Using ULTRA strategy with TPU direct (native QUIC)'); + + // Export signed transaction as base64 + const exported = await builder.export('base64'); + const base64Tx = exported?.data as string; + if (!base64Tx) { + throw new Error('Failed to export transaction'); + } + console.log('[Builder] Transaction exported, size:', base64Tx.length, 'chars'); + + setExecutionState({ status: 'sending' }); + + // Race TPU submission against standard RPC + // TPU sends to validators directly, RPC confirms and returns signature + const racePromises: Promise<{ signature: string; via: string }>[] = []; + + // TPU submission (fast delivery to validators) + racePromises.push( + submitViaTpu(base64Tx).then(result => { + console.log('[Builder] TPU delivered:', result.delivered, 'latency:', result.latencyMs, 'ms'); + if (!result.delivered) { + throw new Error('TPU delivery failed'); + } + // TPU doesn't return signature, will get it from RPC + return { signature: 'tpu-delivered', via: 'tpu' }; + }) + ); + + // Standard RPC submission (to get signature confirmation) + racePromises.push( + builder.execute({ + rpcSubscriptions: compileContext.rpcSubscriptions, + commitment: 'confirmed', + skipPreflight: true, + }).then(sig => ({ signature: sig, via: 'rpc' })) + ); + + // Wait for RPC to confirm (TPU helps it land faster) + // We need the signature from RPC + const results = await Promise.allSettled(racePromises); + const rpcResult = results.find(r => + r.status === 'fulfilled' && r.value.via === 'rpc' + ); + + if (rpcResult && rpcResult.status === 'fulfilled') { + console.log('[Builder] Transaction confirmed via:', rpcResult.value.via); + setExecutionState({ status: 'success', signature: rpcResult.value.signature }); + } else { + // Fallback: if RPC failed, check if TPU succeeded + const tpuResult = results.find(r => + r.status === 'fulfilled' && r.value.via === 'tpu' + ); + if (tpuResult) { + throw new Error('TPU delivered but RPC confirmation failed. Check explorer.'); + } + throw new Error('Both TPU and RPC submission failed'); + } + return; + } + + // For other strategies: use TransactionBuilder.execute() directly + let executeExecution: { + jito?: { enabled: boolean; tipLamports: bigint; blockEngineUrl: string; mevProtection: boolean }; + } | undefined = undefined; + + // Add Jito config if enabled (economical or fast strategies) + if (executionConfig.jito?.enabled) { + const region = executionConfig.jito.region as JitoRegion; + executeExecution = { + jito: { + enabled: true, + tipLamports: executionConfig.jito.tipLamports, + blockEngineUrl: JITO_BLOCK_ENGINES[region] || JITO_BLOCK_ENGINES.mainnet, + mevProtection: true, + }, + }; + console.log('[Builder] Jito enabled with tip:', executionConfig.jito.tipLamports.toString(), 'lamports'); + } + + setExecutionState({ status: 'sending' }); + + const signature = await builder.execute({ + rpcSubscriptions: compileContext.rpcSubscriptions, + commitment: 'confirmed', + skipPreflight: false, + execution: executeExecution, + }); + + console.log('[Builder] Transaction successful:', signature); + setExecutionState({ status: 'success', signature }); + } catch (error) { + console.error('[Builder] Execution error:', error); + + // Provide more helpful error messages + let displayError = error instanceof Error ? error : new Error('Unknown error'); + const errorMsg = displayError.message; + + if (errorMsg.includes('block') && errorMsg.includes('progressed')) { + displayError = new Error('Transaction expired. Please try again - the network was slow to confirm.'); + } else if (errorMsg.includes('User rejected')) { + displayError = new Error('Transaction rejected by wallet.'); + } else if (errorMsg.includes('insufficient funds') || errorMsg.includes('Insufficient')) { + displayError = new Error('Insufficient funds in wallet for this transaction.'); + } + + setExecutionState({ + status: 'error', + error: displayError, + }); + } + }, [nodes, edges, compileContext, config, executionConfig, setExecutionState]); + + // Handle clear + const handleClear = useCallback(() => { + reset(); + setSimulationResult(null); + }, [reset]); + + return ( +
+ {/* Left side - title */} +
+
+ + + +

+ Transaction Builder +

+
+ + {instructionNodeCount} instruction{instructionNodeCount !== 1 ? 's' : ''} + + {cluster && ( + + {cluster.label || cluster.id?.replace('solana:', '')} + + )} + {/* Execution mode badge */} + {executionConfig.strategy !== 'standard' && ( + + {executionConfig.strategy === 'economical' && } + {executionConfig.strategy === 'fast' && } + {executionConfig.strategy === 'ultra' && } + {executionConfig.strategy === 'economical' && 'Jito'} + {executionConfig.strategy === 'fast' && 'Fast'} + {executionConfig.strategy === 'ultra' && 'TPU'} + + )} +
+ + {/* Right side - actions */} +
+ {/* Simulation result indicator */} + {simulationResult && ( +
+ {simulationResult.success ? ( + <> + + + {/* SOL transfers */} + {simulationResult.solTransfer && ( + {simulationResult.solTransfer} + )} + {/* Token transfers */} + {simulationResult.tokenTransfers && simulationResult.tokenTransfers.length > 0 && ( + <> + {simulationResult.solTransfer && ' + '} + {simulationResult.tokenTransfers.map((t, i) => ( + + {i > 0 && ', '} + {t.amount} + {t.symbol} + + ))} + + )} + {/* Separator before CU */} + {(simulationResult.solTransfer || simulationResult.tokenTransfers) && simulationResult.computeUnits && ' ยท '} + {/* Compute units */} + {simulationResult.computeUnits + ? `${simulationResult.computeUnits.toLocaleString()} CU` + : (!simulationResult.solTransfer && !simulationResult.tokenTransfers) ? 'Valid' : ''} + + + ) : ( + <> + + {simulationResult.error?.slice(0, 50)}{(simulationResult.error?.length ?? 0) > 50 ? '...' : ''} + + )} +
+ )} + + {/* Execution state indicator */} + {executionState.status === 'success' && ( + + + {executionState.signature.slice(0, 8)}... + + )} + + {executionState.status === 'error' && ( +
+ + {executionState.error.message.slice(0, 40)}{executionState.error.message.length > 40 ? '...' : ''} +
+ )} + + {/* Clear button */} + + + {/* Simulate button */} + + + {/* Execute button */} + +
+
+ ); +} + diff --git a/examples/next-js/components/builder/feedback-panel.tsx b/examples/next-js/components/builder/feedback-panel.tsx new file mode 100644 index 0000000..5014781 --- /dev/null +++ b/examples/next-js/components/builder/feedback-panel.tsx @@ -0,0 +1,145 @@ +'use client'; + +import { cn } from '@/lib/utils'; +import type { BuilderFeedback } from '@/lib/builder/types'; +import { AlertCircle, CheckCircle, Loader2 } from 'lucide-react'; + +// ============================================================================= +// Props +// ============================================================================= + +interface FeedbackPanelProps { + feedback: BuilderFeedback; +} + +// ============================================================================= +// Meter Component +// ============================================================================= + +interface MeterProps { + label: string; + labelWidth?: string; + value: number; + limit: number; + percentUsed: number; + suffix?: string; + formatValue?: (value: number) => string; +} + +function Meter({ label, labelWidth = 'w-8', value, limit, percentUsed, suffix, formatValue }: MeterProps) { + const isWarning = percentUsed > 80; + const isDanger = percentUsed > 95; + const formattedValue = formatValue ? formatValue(value) : value.toLocaleString(); + const formattedLimit = formatValue ? formatValue(limit) : limit.toLocaleString(); + + return ( +
+ {label} +
+
+
+ + {formattedValue} / {formattedLimit}{suffix ? ` ${suffix}` : ''} + +
+ ); +} + +// ============================================================================= +// Feedback Panel Component +// ============================================================================= + +export function FeedbackPanel({ feedback }: FeedbackPanelProps) { + const { isCompiling, sizeInfo, computeUnitInfo, error } = feedback; + + return ( +
+ {/* Loading state */} + {isCompiling && ( +
+ + Analyzing transaction... +
+ )} + + {/* Size meter */} + {!isCompiling && sizeInfo && ( +
+ +
+ )} + + {/* CU meter */} + {!isCompiling && computeUnitInfo && ( +
+ `~${(v / 1000).toFixed(0)}k`} + /> +
+ )} + + {/* Status indicator */} + {!isCompiling && sizeInfo && ( +
+ {sizeInfo.canFitMore ? ( + <> + + Ready + + ) : ( + <> + + Too large + + )} +
+ )} + + {/* Error/info state */} + {!isCompiling && error && ( +
+ + {error} +
+ )} + + {/* Empty state */} + {!isCompiling && !sizeInfo && !error && ( + + Add nodes and configure them to see transaction details + + )} +
+ ); +} + diff --git a/examples/next-js/components/builder/index.ts b/examples/next-js/components/builder/index.ts new file mode 100644 index 0000000..aac200b --- /dev/null +++ b/examples/next-js/components/builder/index.ts @@ -0,0 +1,13 @@ +/** + * Visual Transaction Builder components. + * + * @packageDocumentation + */ + +export { BuilderCanvas } from './builder-canvas'; +export { BuilderToolbar } from './builder-toolbar'; +export { NodePalette } from './node-palette'; +export { NodeInspector } from './node-inspector'; +export { FeedbackPanel } from './feedback-panel'; +export { nodeTypes } from './nodes'; + diff --git a/examples/next-js/components/builder/node-inspector.tsx b/examples/next-js/components/builder/node-inspector.tsx new file mode 100644 index 0000000..9fd3a00 --- /dev/null +++ b/examples/next-js/components/builder/node-inspector.tsx @@ -0,0 +1,911 @@ +'use client'; + +import { useCallback } from 'react'; +import { cn } from '@/lib/utils'; +import { useBuilderStore } from '@/lib/builder/store'; +import { getNodeDefinition, STRATEGY_INFO, COMMON_TOKENS } from '@/lib/builder/node-definitions'; +import type { NodeType, BuilderNodeData, ExecutionStrategy, JitoRegion } from '@/lib/builder/types'; +import { Trash2, X, Info, Zap, Shield, Rocket, Coins } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Card, CardContent } from '@/components/ui/card'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { TokenListElement } from '@solana/connector/react'; +import { ChevronDown, Check } from 'lucide-react'; + +// ============================================================================= +// Form Field Components +// ============================================================================= + +interface TextFieldProps { + label: string; + value: string; + onChange: (value: string) => void; + placeholder?: string; + type?: 'text' | 'number'; +} + +function TextField({ label, value, onChange, placeholder, type = 'text' }: TextFieldProps) { + return ( +
+ + onChange(e.target.value)} + placeholder={placeholder} + /> +
+ ); +} + +interface TextAreaFieldProps { + label: string; + value: string; + onChange: (value: string) => void; + placeholder?: string; + rows?: number; +} + +function TextAreaField({ label, value, onChange, placeholder, rows = 3 }: TextAreaFieldProps) { + return ( +
+ +