// Grape Elevate β€” Flow builder (interactive drag-and-drop) // // State model: nodes have { id, type, x, y, label, config }; edges have { from, to }. // Canvas supports pan with middle/right drag or holding space; pinch/wheel zoom. // Nodes drag on the canvas; each has an output dot (bottom) you can drag from // to another node's input dot (top) to wire a connection. const NODE_W = 220; const NODE_H_DEFAULT = 92; const PORT_R = 7; const NODE_TYPES = { // triggers comment: { cat: 'trigger', color: 'var(--coral)', icon: Icons.Comment, labelKey: 'flow.node.comment', defaults: { keyword: 'PROMO', post: 'ΓΊltimo post' } }, dm: { cat: 'trigger', color: 'var(--cyan)', icon: Icons.Send, labelKey: 'flow.node.dm', defaults: { keyword: 'oi' } }, story: { cat: 'trigger', color: 'var(--grape)', icon: Icons.Story, labelKey: 'flow.node.story', defaults: { keyword: '*' } }, follow: { cat: 'trigger', color: 'var(--sun)', icon: Icons.Follow, labelKey: 'flow.node.follow', defaults: {} }, // actions sendDm: { cat: 'action', color: 'var(--cyan)', icon: Icons.Send, labelKey: 'flow.node.sendDm', defaults: { text: 'Oi! Aqui estΓ‘ o que vocΓͺ pediu πŸ‘‡' } }, sendLink: { cat: 'action', color: 'var(--grape)', icon: Icons.Link, labelKey: 'flow.node.sendLink', defaults: { url: 'https://grape.app/oferta', cta: 'Ver oferta' } }, tag: { cat: 'action', color: 'var(--sun)', icon: Icons.Tag, labelKey: 'flow.node.tag', defaults: { tag: 'lead-quente' } }, // logic wait: { cat: 'logic', color: 'var(--text-mute)', icon: Icons.Clock, labelKey: 'flow.node.wait', defaults: { mins: 5 } }, if: { cat: 'logic', color: 'var(--green)', icon: Icons.Branch, labelKey: 'flow.node.if', defaults: { cond: 'tem tag "vip"' } }, split: { cat: 'logic', color: 'var(--green)', icon: Icons.Branch, labelKey: 'flow.node.split', defaults: { ratio: '50/50' } }, }; const initialFlow = () => ({ nodes: [ { id: 'n1', type: 'comment', x: 120, y: 60, config: { keyword: 'PROMO', post: 'Reel "3 dicas"' } }, { id: 'n2', type: 'wait', x: 120, y: 240, config: { mins: 1 } }, { id: 'n3', type: 'sendDm', x: 120, y: 380, config: { text: 'Oi! Vi seu comentΓ‘rio πŸ’œ mando aqui em segundos…' } }, { id: 'n4', type: 'sendLink', x: 120, y: 540, config: { url: 'https://grape.app/promo', cta: 'Aproveitar' } }, { id: 'n5', type: 'tag', x: 420, y: 380, config: { tag: 'promo-jun' } }, ], edges: [ { from: 'n1', to: 'n2' }, { from: 'n2', to: 'n3' }, { from: 'n3', to: 'n4' }, { from: 'n3', to: 'n5' }, ], }); function FlowBuilder({ t, lang, automations, onBack, onSaveToast }) { const [flow, setFlow] = React.useState(initialFlow); const [selected, setSelected] = React.useState('n1'); const [zoom, setZoom] = React.useState(0.85); const [pan, setPan] = React.useState({ x: 80, y: 20 }); const [dragging, setDragging] = React.useState(null); // { id, dx, dy } const [connecting, setConnecting] = React.useState(null); // { from, x, y } const canvasRef = React.useRef(null); const sel = flow.nodes.find((n) => n.id === selected); // Add a new node by clicking a palette tile. Drops near center of viewport. const addNode = (type) => { const id = 'n' + Date.now(); const x = (-pan.x + 400) / zoom; const y = (-pan.y + 240) / zoom; setFlow((f) => ({ ...f, nodes: [...f.nodes, { id, type, x, y, config: { ...(NODE_TYPES[type].defaults || {}) } }], })); setSelected(id); }; const deleteNode = (id) => setFlow((f) => ({ nodes: f.nodes.filter((n) => n.id !== id), edges: f.edges.filter((e) => e.from !== id && e.to !== id), })); const duplicateNode = (id) => { const n = flow.nodes.find((x) => x.id === id); if (!n) return; const nid = 'n' + Date.now(); setFlow((f) => ({ ...f, nodes: [...f.nodes, { ...n, id: nid, x: n.x + 24, y: n.y + 24, config: { ...n.config } }] })); setSelected(nid); }; const updateConfig = (id, key, value) => setFlow((f) => ({ ...f, nodes: f.nodes.map((n) => n.id === id ? { ...n, config: { ...n.config, [key]: value } } : n), })); // ── Canvas pan / zoom ───────────────────────────────────────────────────── const onWheel = (e) => { if (!e.ctrlKey && !e.metaKey && Math.abs(e.deltaY) < 30) { setPan((p) => ({ x: p.x - e.deltaX, y: p.y - e.deltaY })); return; } e.preventDefault(); const rect = canvasRef.current.getBoundingClientRect(); const cx = e.clientX - rect.left, cy = e.clientY - rect.top; const next = Math.max(0.4, Math.min(1.6, zoom - e.deltaY * 0.001)); const k = next / zoom; setPan((p) => ({ x: cx - (cx - p.x) * k, y: cy - (cy - p.y) * k })); setZoom(next); }; const startPan = (e) => { if (e.target !== canvasRef.current && !e.target.classList.contains('fb-grid')) return; setSelected(null); const sx = e.clientX, sy = e.clientY, p0 = { ...pan }; const move = (ev) => setPan({ x: p0.x + (ev.clientX - sx), y: p0.y + (ev.clientY - sy) }); const up = () => { window.removeEventListener('mousemove', move); window.removeEventListener('mouseup', up); }; window.addEventListener('mousemove', move); window.addEventListener('mouseup', up); }; // ── Node drag ───────────────────────────────────────────────────────────── const startNodeDrag = (e, id) => { e.stopPropagation(); setSelected(id); const n = flow.nodes.find((x) => x.id === id); const sx = e.clientX, sy = e.clientY, x0 = n.x, y0 = n.y; const move = (ev) => { const nx = x0 + (ev.clientX - sx) / zoom; const ny = y0 + (ev.clientY - sy) / zoom; setFlow((f) => ({ ...f, nodes: f.nodes.map((m) => m.id === id ? { ...m, x: nx, y: ny } : m) })); }; const up = () => { window.removeEventListener('mousemove', move); window.removeEventListener('mouseup', up); }; window.addEventListener('mousemove', move); window.addEventListener('mouseup', up); }; // ── Wire dragging ───────────────────────────────────────────────────────── const startWire = (e, fromId) => { e.stopPropagation(); const rect = canvasRef.current.getBoundingClientRect(); setConnecting({ from: fromId, x: (e.clientX - rect.left - pan.x) / zoom, y: (e.clientY - rect.top - pan.y) / zoom }); const move = (ev) => { const rr = canvasRef.current.getBoundingClientRect(); setConnecting((c) => c && ({ ...c, x: (ev.clientX - rr.left - pan.x) / zoom, y: (ev.clientY - rr.top - pan.y) / zoom })); }; const up = (ev) => { window.removeEventListener('mousemove', move); window.removeEventListener('mouseup', up); // Drop on a node input? const targets = document.elementsFromPoint(ev.clientX, ev.clientY); const portEl = targets.find((el) => el?.dataset && el.dataset.inputFor); if (portEl) { const toId = portEl.dataset.inputFor; if (toId !== fromId) { setFlow((f) => { // Replace any existing edge from same source if you like; here we just append unless duplicate. if (f.edges.some((e) => e.from === fromId && e.to === toId)) return f; return { ...f, edges: [...f.edges, { from: fromId, to: toId }] }; }); } } setConnecting(null); }; window.addEventListener('mousemove', move); window.addEventListener('mouseup', up); }; // ── Edge path (bezier) ──────────────────────────────────────────────────── const edgePath = (a, b) => { const x1 = a.x + NODE_W / 2, y1 = a.y + NODE_H_DEFAULT; const x2 = b.x + NODE_W / 2, y2 = b.y; const dy = Math.max(40, (y2 - y1) / 2); return `M ${x1},${y1} C ${x1},${y1 + dy} ${x2},${y2 - dy} ${x2},${y2}`; }; // Palette grouped by category const palette = { triggers: ['comment', 'dm', 'story', 'follow'], actions: ['sendDm', 'sendLink', 'tag'], logic: ['wait', 'if', 'split'], }; return ( <>
{t('flow.title')}
{/* Palette */} {/* Canvas */}
{/* Edges */} {flow.edges.map((e, i) => { const a = flow.nodes.find((n) => n.id === e.from); const b = flow.nodes.find((n) => n.id === e.to); if (!a || !b) return null; return ; })} {connecting && (() => { const a = flow.nodes.find((n) => n.id === connecting.from); if (!a) return null; const x1 = a.x + NODE_W / 2, y1 = a.y + NODE_H_DEFAULT; const dy = Math.max(40, (connecting.y - y1) / 2); return ; })()} {/* Nodes */} {flow.nodes.map((n) => { const T = NODE_TYPES[n.type]; if (!T) return null; const isSel = selected === n.id; return (
startNodeDrag(e, n.id)}>
{t(T.labelKey)} {t(`flow.cat.${T.cat === 'trigger' ? 'triggers' : T.cat === 'action' ? 'actions' : 'logic'}`).slice(0, 3).toUpperCase()}
{T.cat !== 'trigger' && (
)}
startWire(e, n.id)} />
); })}
{/* Inspector */}
); } const NodeBody = ({ type, config }) => { const c = config || {}; if (type === 'comment') return
quando comentar "{c.keyword}" em {c.post}
; if (type === 'dm') return
quando alguΓ©m mandar DM com "{c.keyword}"
; if (type === 'story') return
quando responder ao story
; if (type === 'follow') return
quando comeΓ§ar a seguir
; if (type === 'sendDm') return
{c.text}
; if (type === 'sendLink') return
πŸ”— {c.cta || 'Abrir'}
{c.url}
; if (type === 'tag') return
adicionar tag #{c.tag}
; if (type === 'wait') return
aguardar {c.mins} min
; if (type === 'if') return
se {c.cond}
; if (type === 'split') return
dividir trΓ‘fego {c.ratio}
; return null; }; function Inspector({ sel, t, onUpdate, onDelete, onDup }) { const T = NODE_TYPES[sel.type]; const fields = (() => { if (sel.type === 'comment') return [{ k: 'keyword', label: 'Palavra-chave' }, { k: 'post', label: 'Em qual post' }]; if (sel.type === 'dm') return [{ k: 'keyword', label: 'Palavra-chave' }]; if (sel.type === 'story') return [{ k: 'keyword', label: 'Resposta contΓ©m', placeholder: '* = qualquer' }]; if (sel.type === 'sendDm') return [{ k: 'text', label: 'Mensagem', area: true }]; if (sel.type === 'sendLink') return [{ k: 'cta', label: 'Texto do botΓ£o' }, { k: 'url', label: 'URL' }]; if (sel.type === 'tag') return [{ k: 'tag', label: 'Tag' }]; if (sel.type === 'wait') return [{ k: 'mins', label: 'Minutos', type: 'number' }]; if (sel.type === 'if') return [{ k: 'cond', label: 'CondiΓ§Γ£o' }]; if (sel.type === 'split') return [{ k: 'ratio', label: 'DistribuiΓ§Γ£o' }]; return []; })(); return (
{t('flow.inspect')}
{t(T.labelKey)}
{fields.map((f) => (
{f.area ? (