// 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 (
<>
{/* Palette */}
{/* Canvas */}
{/* Edges */}
{/* 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 ? (
))}
);
}
const flowStyles = `
.fb-bar { display: flex; align-items: center; gap: 12px; padding: 0 4px; }
.fb-title { display: flex; flex-direction: column; flex: 1; min-width: 0; }
.fb-name { border: 0; background: transparent; font-family: inherit; font-size: 18px; font-weight: 700; color: var(--text); padding: 2px 0; outline: none; letter-spacing: -0.01em; }
.fb-name:focus { border-bottom: 1px dashed var(--grape); }
.fb-shell {
display: grid;
grid-template-columns: 240px 1fr 280px;
gap: 0;
height: calc(100vh - 100px);
border: 1px solid var(--line);
border-radius: var(--radius-lg);
background: var(--surface);
overflow: hidden;
box-shadow: var(--shadow-card);
}
.fb-palette, .fb-inspector { padding: 14px; overflow-y: auto; background: var(--surface-2); }
.fb-palette { border-right: 1px solid var(--line); display: flex; flex-direction: column; gap: 12px; }
.fb-inspector { border-left: 1px solid var(--line); }
.fb-cat-label { font-size: 11px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-mute); margin-bottom: 6px; padding: 0 4px; }
.fb-pal-grid { display: flex; flex-direction: column; gap: 6px; }
.fb-pal-item {
display: flex; align-items: center; gap: 10px;
width: 100%;
padding: 8px 10px;
border-radius: 10px;
background: var(--surface);
border: 1px solid var(--line);
cursor: pointer;
font: inherit;
font-size: 13px;
font-weight: 500;
color: var(--text);
text-align: left;
transition: transform 80ms, border-color 120ms, background 120ms;
}
.fb-pal-item:hover { transform: translateY(-1px); border-color: var(--line-strong); background: var(--surface); }
.fb-pal-item:hover svg:last-child { color: var(--grape); }
.fb-pal-item svg:last-child { color: var(--text-mute); margin-left: auto; }
.fb-pal-ic { width: 26px; height: 26px; border-radius: 8px; display: inline-flex; align-items: center; justify-content: center; }
.fb-pal-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fb-tip { margin-top: auto; padding: 10px; border-radius: 10px; background: color-mix(in oklch, var(--grape) 10%, var(--surface)); color: var(--text-soft); font-size: 11.5px; line-height: 1.45; }
.fb-canvas-wrap {
position: relative;
overflow: hidden;
background: var(--surface-2);
cursor: grab;
}
.fb-canvas-wrap:active { cursor: grabbing; }
.fb-grid {
position: absolute; inset: 0;
background-image:
radial-gradient(color-mix(in oklch, var(--text-mute) 30%, transparent) 1px, transparent 1px);
background-size: 22px 22px;
background-position: 0 0;
opacity: 0.5;
pointer-events: none;
}
.fb-world { position: absolute; top: 0; left: 0; transform-origin: 0 0; }
.fb-edges { position: absolute; top: 0; left: 0; pointer-events: none; overflow: visible; }
.fb-node {
position: absolute;
background: var(--surface);
border-radius: 14px;
border: 1.5px solid var(--line);
box-shadow: 0 1px 0 rgba(255,255,255,.04) inset, 0 4px 14px -6px rgba(20,18,40,.18);
font-size: 12.5px;
cursor: grab;
user-select: none;
}
.fb-node:active { cursor: grabbing; }
.fb-node.sel { border-color: var(--grape); box-shadow: 0 0 0 4px color-mix(in oklch, var(--grape) 22%, transparent), 0 8px 24px -6px rgba(20,18,40,.25); }
.fb-node-head { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 12px 12px 0 0; }
.fb-node-ic { width: 22px; height: 22px; border-radius: 7px; display: inline-flex; align-items: center; justify-content: center; }
.fb-node-title { font-weight: 700; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fb-node-cat { font-size: 10px; font-weight: 700; letter-spacing: 0.08em; color: var(--text-mute); }
.fb-node-body { padding: 10px 12px 14px; color: var(--text-soft); line-height: 1.4; word-break: break-word; }
.fb-node-body code, .fb-node-body .trigger-key { font-family: var(--font-mono); font-size: 11.5px; }
.fb-port { position: absolute; left: 50%; transform: translateX(-50%); width: 14px; height: 14px; border-radius: 50%; background: var(--surface); border: 2px solid var(--text-mute); cursor: crosshair; }
.fb-port:hover { background: var(--grape); border-color: var(--grape); }
.fb-port-in { top: -8px; }
.fb-port-out { bottom: -8px; background: var(--text-mute); border-color: var(--surface); }
.fb-port-out:hover { background: var(--grape); }
.mock-bubble { background: linear-gradient(135deg, color-mix(in oklch, var(--cyan) 12%, var(--surface)), color-mix(in oklch, var(--grape) 10%, var(--surface))); padding: 8px 10px; border-radius: 12px 12px 12px 4px; color: var(--text); font-size: 12px; }
.mock-link { background: var(--surface-3); padding: 6px 10px; border-radius: 999px; display: inline-block; font-weight: 600; font-size: 12px; }
.insp-head { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
.insp-field { display: flex; flex-direction: column; gap: 4px; margin-bottom: 10px; }
.insp-field label { font-size: 11.5px; font-weight: 600; color: var(--text-soft); }
.insp-field .input, .insp-field textarea { width: 100%; font-family: inherit; }
.insp-field textarea { border: 1px solid var(--line); border-radius: 10px; background: var(--surface); color: var(--text); font-size: 13px; outline: none; resize: vertical; }
.insp-field textarea:focus { border-color: var(--grape); box-shadow: 0 0 0 3px color-mix(in oklch, var(--grape) 22%, transparent); }
`;
window.FlowBuilder = FlowBuilder;