diff --git a/modules/rsocials/campaign-data.ts b/modules/rsocials/campaign-data.ts
index 3cca41d..f0500d0 100644
--- a/modules/rsocials/campaign-data.ts
+++ b/modules/rsocials/campaign-data.ts
@@ -6,7 +6,7 @@
* Also exports buildDemoCampaignFlow() for the canvas planner.
*/
-import type { CampaignFlow, CampaignPlannerNode, CampaignEdge } from './schemas';
+import type { CampaignFlow, CampaignPlannerNode, CampaignEdge, CampaignWorkflow, CampaignWorkflowNode, CampaignWorkflowEdge } from './schemas';
export interface CampaignPost {
id: string;
@@ -303,3 +303,87 @@ export function buildDemoCampaignFlow(): CampaignFlow {
createdBy: null,
};
}
+
+// ── Build demo campaign workflow (n8n-style) ──
+// Campaign Start → Post to X → Wait 2h → Cross-Post (LinkedIn + Threads) → Engagement Check → Send Newsletter
+
+export function buildDemoCampaignWorkflow(): CampaignWorkflow {
+ const nodes: CampaignWorkflowNode[] = [
+ {
+ id: 'cw-start',
+ type: 'campaign-start',
+ label: 'Campaign Start',
+ position: { x: 50, y: 120 },
+ config: { description: 'MycoFi Earth launch sequence' },
+ },
+ {
+ id: 'cw-post-x',
+ type: 'post-to-platform',
+ label: 'Post to X',
+ position: { x: 340, y: 100 },
+ config: {
+ platform: 'X',
+ content: 'Something is growing in the mycelium... \uD83C\uDF44\n\nMycoFi Earth launches today. A regenerative finance platform modeled on mycelial networks.\n\nNo VCs. No whales. Just communities funding what matters.',
+ hashtags: '#MycoFi #RegenFinance #Web3',
+ },
+ },
+ {
+ id: 'cw-wait',
+ type: 'wait-duration',
+ label: 'Wait 2 Hours',
+ position: { x: 630, y: 120 },
+ config: { amount: 2, unit: 'hours' },
+ },
+ {
+ id: 'cw-crosspost',
+ type: 'cross-post',
+ label: 'Cross-Post',
+ position: { x: 920, y: 80 },
+ config: {
+ platforms: 'LinkedIn, Threads',
+ content: 'MycoFi Earth is live — a regenerative finance platform where funding flows like nutrients through a mycelial network.',
+ adaptPerPlatform: 'yes',
+ },
+ },
+ {
+ id: 'cw-engage',
+ type: 'engagement-check',
+ label: 'Engagement Check',
+ position: { x: 1210, y: 60 },
+ config: { metric: 'likes', threshold: 50 },
+ },
+ {
+ id: 'cw-newsletter',
+ type: 'send-newsletter',
+ label: 'Send Newsletter',
+ position: { x: 1500, y: 40 },
+ config: {
+ subject: 'MycoFi Earth is Live!',
+ listId: '1',
+ bodyTemplate: '
MycoFi Earth Launch
The regenerative finance platform is now live. Join the first funding circle.
',
+ },
+ },
+ ];
+
+ const edges: CampaignWorkflowEdge[] = [
+ { id: 'cwe-1', fromNode: 'cw-start', fromPort: 'trigger', toNode: 'cw-post-x', toPort: 'trigger' },
+ { id: 'cwe-2', fromNode: 'cw-post-x', fromPort: 'done', toNode: 'cw-wait', toPort: 'trigger' },
+ { id: 'cwe-3', fromNode: 'cw-wait', fromPort: 'done', toNode: 'cw-crosspost', toPort: 'trigger' },
+ { id: 'cwe-4', fromNode: 'cw-crosspost', fromPort: 'done', toNode: 'cw-engage', toPort: 'trigger' },
+ { id: 'cwe-5', fromNode: 'cw-engage', fromPort: 'above', toNode: 'cw-newsletter', toPort: 'trigger' },
+ ];
+
+ const now = Date.now();
+ return {
+ id: 'demo-mycofi-workflow',
+ name: 'MycoFi Launch Workflow',
+ enabled: true,
+ nodes,
+ edges,
+ lastRunAt: null,
+ lastRunStatus: null,
+ runCount: 0,
+ createdAt: now,
+ updatedAt: now,
+ };
+}
diff --git a/modules/rsocials/components/campaign-workflow.css b/modules/rsocials/components/campaign-workflow.css
new file mode 100644
index 0000000..09c4950
--- /dev/null
+++ b/modules/rsocials/components/campaign-workflow.css
@@ -0,0 +1,517 @@
+/* rSocials Campaign Workflow — n8n-style workflow builder */
+folk-campaign-workflow {
+ display: block;
+ height: calc(100vh - 60px);
+}
+
+.cw-root {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ font-family: system-ui, -apple-system, sans-serif;
+ color: var(--rs-text-primary, #e2e8f0);
+}
+
+/* ── Toolbar ── */
+.cw-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 10px 20px;
+ min-height: 46px;
+ border-bottom: 1px solid var(--rs-border, #2d2d44);
+ background: var(--rs-bg-surface, #1a1a2e);
+ z-index: 10;
+}
+
+.cw-toolbar__title {
+ font-size: 15px;
+ font-weight: 600;
+ flex: 1;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.cw-toolbar__title input {
+ background: transparent;
+ border: 1px solid transparent;
+ color: var(--rs-text-primary, #e2e8f0);
+ font-size: 15px;
+ font-weight: 600;
+ padding: 2px 6px;
+ border-radius: 4px;
+ width: 200px;
+}
+
+.cw-toolbar__title input:hover,
+.cw-toolbar__title input:focus {
+ border-color: var(--rs-border-strong, #3d3d5c);
+ outline: none;
+}
+
+.cw-toolbar__actions {
+ display: flex;
+ gap: 6px;
+ align-items: center;
+}
+
+.cw-btn {
+ padding: 6px 12px;
+ border-radius: 8px;
+ border: 1px solid var(--rs-input-border, #3d3d5c);
+ background: var(--rs-input-bg, #16162a);
+ color: var(--rs-text-primary, #e2e8f0);
+ font-size: 12px;
+ cursor: pointer;
+ transition: border-color 0.15s, background 0.15s;
+ white-space: nowrap;
+}
+
+.cw-btn:hover {
+ border-color: var(--rs-border-strong, #4d4d6c);
+}
+
+.cw-btn--run {
+ background: #3b82f622;
+ border-color: #3b82f655;
+ color: #60a5fa;
+}
+
+.cw-btn--run:hover {
+ background: #3b82f633;
+ border-color: #3b82f6;
+}
+
+.cw-toggle {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 12px;
+ color: var(--rs-text-muted, #94a3b8);
+}
+
+.cw-toggle input[type="checkbox"] {
+ accent-color: #10b981;
+}
+
+.cw-save-indicator {
+ font-size: 11px;
+ color: var(--rs-text-muted, #64748b);
+}
+
+/* ── Canvas area ── */
+.cw-canvas-area {
+ flex: 1;
+ display: flex;
+ overflow: hidden;
+ position: relative;
+}
+
+/* ── Left sidebar — node palette ── */
+.cw-palette {
+ width: 200px;
+ min-width: 200px;
+ border-right: 1px solid var(--rs-border, #2d2d44);
+ background: var(--rs-bg-surface, #1a1a2e);
+ overflow-y: auto;
+ padding: 12px;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.cw-palette__group-title {
+ font-size: 10px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--rs-text-muted, #94a3b8);
+ margin-bottom: 4px;
+}
+
+.cw-palette__card {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 10px;
+ border-radius: 8px;
+ border: 1px solid var(--rs-border, #2d2d44);
+ background: var(--rs-input-bg, #16162a);
+ cursor: grab;
+ font-size: 12px;
+ transition: border-color 0.15s, background 0.15s;
+ margin-bottom: 4px;
+}
+
+.cw-palette__card:hover {
+ border-color: #6366f1;
+ background: #6366f111;
+}
+
+.cw-palette__card:active {
+ cursor: grabbing;
+}
+
+.cw-palette__card-icon {
+ font-size: 16px;
+ width: 24px;
+ text-align: center;
+ flex-shrink: 0;
+}
+
+.cw-palette__card-label {
+ font-weight: 500;
+ color: var(--rs-text-primary, #e2e8f0);
+}
+
+/* ── SVG canvas ── */
+.cw-canvas {
+ flex: 1;
+ position: relative;
+ overflow: hidden;
+ cursor: grab;
+ background: var(--rs-canvas-bg, #0f0f23);
+}
+
+.cw-canvas.grabbing {
+ cursor: grabbing;
+}
+
+.cw-canvas.wiring {
+ cursor: crosshair;
+}
+
+.cw-canvas svg {
+ display: block;
+ width: 100%;
+ height: 100%;
+}
+
+/* ── Right sidebar — config ── */
+.cw-config {
+ width: 0;
+ overflow: hidden;
+ border-left: 1px solid var(--rs-border, #2d2d44);
+ background: var(--rs-bg-surface, #1a1a2e);
+ display: flex;
+ flex-direction: column;
+ transition: width 0.2s ease;
+}
+
+.cw-config.open {
+ width: 280px;
+ min-width: 280px;
+}
+
+.cw-config__header {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 12px 16px;
+ border-bottom: 1px solid var(--rs-border, #2d2d44);
+ font-weight: 600;
+ font-size: 13px;
+}
+
+.cw-config__header-close {
+ background: none;
+ border: none;
+ color: var(--rs-text-muted, #94a3b8);
+ font-size: 16px;
+ cursor: pointer;
+ margin-left: auto;
+ padding: 2px;
+}
+
+.cw-config__body {
+ padding: 12px 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ overflow-y: auto;
+ flex: 1;
+}
+
+.cw-config__field {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.cw-config__field label {
+ font-size: 11px;
+ font-weight: 500;
+ color: var(--rs-text-muted, #94a3b8);
+}
+
+.cw-config__field input,
+.cw-config__field select,
+.cw-config__field textarea {
+ width: 100%;
+ padding: 6px 8px;
+ border-radius: 6px;
+ border: 1px solid var(--rs-input-border, #3d3d5c);
+ background: var(--rs-input-bg, #16162a);
+ color: var(--rs-text-primary, #e2e8f0);
+ font-size: 12px;
+ font-family: inherit;
+ box-sizing: border-box;
+}
+
+.cw-config__field textarea {
+ resize: vertical;
+ min-height: 60px;
+}
+
+.cw-config__field input:focus,
+.cw-config__field select:focus,
+.cw-config__field textarea:focus {
+ border-color: #3b82f6;
+ outline: none;
+}
+
+.cw-config__delete {
+ margin-top: 12px;
+ padding: 6px 12px;
+ border-radius: 6px;
+ border: 1px solid #ef444455;
+ background: #ef444422;
+ color: #f87171;
+ font-size: 12px;
+ cursor: pointer;
+}
+
+.cw-config__delete:hover {
+ background: #ef444433;
+}
+
+/* ── Execution log in config panel ── */
+.cw-exec-log {
+ margin-top: 12px;
+ border-top: 1px solid var(--rs-border, #2d2d44);
+ padding-top: 12px;
+}
+
+.cw-exec-log__title {
+ font-size: 11px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--rs-text-muted, #94a3b8);
+ margin-bottom: 8px;
+}
+
+.cw-exec-log__entry {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 4px 0;
+ font-size: 11px;
+ color: var(--rs-text-muted, #94a3b8);
+}
+
+.cw-exec-log__dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+.cw-exec-log__dot.success { background: #22c55e; }
+.cw-exec-log__dot.error { background: #ef4444; }
+.cw-exec-log__dot.running { background: #3b82f6; animation: cw-pulse 1s infinite; }
+
+@keyframes cw-pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.4; }
+}
+
+/* ── Zoom controls ── */
+.cw-zoom-controls {
+ position: absolute;
+ bottom: 12px;
+ right: 12px;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ background: var(--rs-bg-surface, #1a1a2e);
+ border: 1px solid var(--rs-border-strong, #3d3d5c);
+ border-radius: 8px;
+ padding: 4px 6px;
+ z-index: 5;
+}
+
+.cw-zoom-btn {
+ width: 28px;
+ height: 28px;
+ border: none;
+ border-radius: 6px;
+ background: transparent;
+ color: var(--rs-text-primary, #e2e8f0);
+ font-size: 16px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: background 0.15s;
+}
+
+.cw-zoom-btn:hover {
+ background: var(--rs-bg-surface-raised, #252545);
+}
+
+.cw-zoom-level {
+ font-size: 11px;
+ color: var(--rs-text-muted, #94a3b8);
+ min-width: 36px;
+ text-align: center;
+}
+
+/* ── Node styles in SVG ── */
+.cw-node { cursor: pointer; }
+.cw-node.selected > foreignObject > div {
+ outline: 2px solid #6366f1;
+ outline-offset: 2px;
+}
+
+.cw-node foreignObject > div {
+ border-radius: 10px;
+ border: 1px solid var(--rs-border, #2d2d44);
+ background: var(--rs-bg-surface, #1a1a2e);
+ overflow: hidden;
+ font-size: 12px;
+ transition: border-color 0.15s;
+}
+
+.cw-node:hover foreignObject > div {
+ border-color: #4f46e5 !important;
+}
+
+.cw-node-header {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 8px 10px;
+ border-bottom: 1px solid var(--rs-border, #2d2d44);
+ cursor: move;
+}
+
+.cw-node-icon {
+ font-size: 14px;
+}
+
+.cw-node-label {
+ font-weight: 600;
+ font-size: 12px;
+ color: var(--rs-text-primary, #e2e8f0);
+ flex: 1;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.cw-node-status {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+.cw-node-status.idle { background: #4b5563; }
+.cw-node-status.running { background: #3b82f6; animation: cw-pulse 1s infinite; }
+.cw-node-status.success { background: #22c55e; }
+.cw-node-status.error { background: #ef4444; }
+
+.cw-node-ports {
+ padding: 6px 10px;
+ display: flex;
+ justify-content: space-between;
+ min-height: 28px;
+}
+
+.cw-node-inputs,
+.cw-node-outputs {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.cw-node-outputs {
+ align-items: flex-end;
+}
+
+.cw-port-label {
+ font-size: 10px;
+ color: var(--rs-text-muted, #94a3b8);
+}
+
+/* ── Port handles in SVG ── */
+.cw-port-group { cursor: crosshair; }
+.cw-port-dot {
+ transition: r 0.15s, filter 0.15s;
+}
+
+.cw-port-group:hover .cw-port-dot {
+ r: 7;
+ filter: drop-shadow(0 0 4px currentColor);
+}
+
+/* ── Edge styles ── */
+.cw-edge-group { pointer-events: stroke; }
+
+.cw-edge-path {
+ fill: none;
+ stroke-width: 2;
+}
+
+.cw-edge-hit {
+ fill: none;
+ stroke: transparent;
+ stroke-width: 16;
+ cursor: pointer;
+}
+
+/* ── Wiring temp line ── */
+.cw-wiring-temp {
+ fill: none;
+ stroke: #6366f1;
+ stroke-width: 2;
+ stroke-dasharray: 6 4;
+ opacity: 0.7;
+ pointer-events: none;
+}
+
+/* ── Workflow selector ── */
+.cw-workflow-select {
+ padding: 4px 8px;
+ border-radius: 6px;
+ border: 1px solid var(--rs-input-border, #3d3d5c);
+ background: var(--rs-input-bg, #16162a);
+ color: var(--rs-text-primary, #e2e8f0);
+ font-size: 12px;
+}
+
+/* ── Mobile ── */
+@media (max-width: 768px) {
+ .cw-palette {
+ width: 160px;
+ min-width: 160px;
+ padding: 8px;
+ }
+
+ .cw-config.open {
+ position: absolute;
+ top: 0;
+ right: 0;
+ width: 100%;
+ height: 100%;
+ z-index: 20;
+ min-width: unset;
+ }
+
+ .cw-toolbar {
+ flex-wrap: wrap;
+ padding: 8px 12px;
+ }
+}
diff --git a/modules/rsocials/components/folk-campaign-workflow.ts b/modules/rsocials/components/folk-campaign-workflow.ts
new file mode 100644
index 0000000..41d027e
--- /dev/null
+++ b/modules/rsocials/components/folk-campaign-workflow.ts
@@ -0,0 +1,1003 @@
+/**
+ * — n8n-style campaign workflow builder for rSocials.
+ *
+ * Renders social-media-specific workflow nodes (triggers, delays, conditions,
+ * actions) on an SVG canvas with ports, Bezier wiring, node palette, config
+ * panel, and REST persistence.
+ *
+ * Attributes:
+ * space — space slug (default "demo")
+ */
+
+import { CAMPAIGN_NODE_CATALOG } from '../schemas';
+import type {
+ CampaignWorkflowNodeDef,
+ CampaignWorkflowNodeCategory,
+ CampaignWorkflowNode,
+ CampaignWorkflowEdge,
+ CampaignWorkflow,
+} from '../schemas';
+
+// ── Constants ──
+
+const NODE_WIDTH = 220;
+const NODE_HEIGHT = 80;
+const PORT_RADIUS = 5;
+
+const CATEGORY_COLORS: Record = {
+ trigger: '#3b82f6',
+ delay: '#a855f7',
+ condition: '#f59e0b',
+ action: '#10b981',
+};
+
+const PORT_COLORS: Record = {
+ trigger: '#ef4444',
+ data: '#3b82f6',
+ boolean: '#f59e0b',
+};
+
+// ── Helpers ──
+
+function esc(s: string): string {
+ const d = document.createElement('div');
+ d.textContent = s || '';
+ return d.innerHTML;
+}
+
+function getNodeDef(type: string): CampaignWorkflowNodeDef | undefined {
+ return CAMPAIGN_NODE_CATALOG.find(n => n.type === type);
+}
+
+function getPortX(node: CampaignWorkflowNode, _portName: string, direction: 'input' | 'output'): number {
+ return direction === 'input' ? node.position.x : node.position.x + NODE_WIDTH;
+}
+
+function getPortY(node: CampaignWorkflowNode, portName: string, direction: 'input' | 'output'): number {
+ const def = getNodeDef(node.type);
+ if (!def) return node.position.y + NODE_HEIGHT / 2;
+ const ports = direction === 'input' ? def.inputs : def.outputs;
+ const idx = ports.findIndex(p => p.name === portName);
+ if (idx === -1) return node.position.y + NODE_HEIGHT / 2;
+ const spacing = NODE_HEIGHT / (ports.length + 1);
+ return node.position.y + spacing * (idx + 1);
+}
+
+function bezierPath(x1: number, y1: number, x2: number, y2: number): string {
+ const dx = Math.abs(x2 - x1) * 0.5;
+ return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;
+}
+
+// ── Component ──
+
+class FolkCampaignWorkflow extends HTMLElement {
+ private shadow: ShadowRoot;
+ private space = '';
+
+ private get basePath() {
+ const host = window.location.hostname;
+ if (host.endsWith('.rspace.online')) return '/rsocials/';
+ return `/${this.space}/rsocials/`;
+ }
+
+ // Data
+ private workflows: CampaignWorkflow[] = [];
+ private currentWorkflowId = '';
+ private nodes: CampaignWorkflowNode[] = [];
+ private edges: CampaignWorkflowEdge[] = [];
+ private workflowName = 'New Campaign Workflow';
+ private workflowEnabled = true;
+
+ // Canvas state
+ private canvasZoom = 1;
+ private canvasPanX = 0;
+ private canvasPanY = 0;
+
+ // Interaction
+ private isPanning = false;
+ private panStartX = 0;
+ private panStartY = 0;
+ private panStartPanX = 0;
+ private panStartPanY = 0;
+ private draggingNodeId: string | null = null;
+ private dragStartX = 0;
+ private dragStartY = 0;
+ private dragNodeStartX = 0;
+ private dragNodeStartY = 0;
+
+ // Selection & config
+ private selectedNodeId: string | null = null;
+ private configOpen = false;
+
+ // Wiring
+ private wiringActive = false;
+ private wiringSourceNodeId: string | null = null;
+ private wiringSourcePortName: string | null = null;
+ private wiringSourceDir: 'input' | 'output' | null = null;
+ private wiringPointerX = 0;
+ private wiringPointerY = 0;
+
+ // Persistence
+ private saveTimer: ReturnType | null = null;
+ private saveIndicator = '';
+
+ // Execution log
+ private execLog: { nodeId: string; status: string; message: string; durationMs: number }[] = [];
+
+ // Bound listeners
+ private _boundPointerMove: ((e: PointerEvent) => void) | null = null;
+ private _boundPointerUp: ((e: PointerEvent) => void) | null = null;
+ private _boundKeyDown: ((e: KeyboardEvent) => void) | null = null;
+
+ constructor() {
+ super();
+ this.shadow = this.attachShadow({ mode: 'open' });
+ }
+
+ connectedCallback() {
+ this.space = this.getAttribute('space') || 'demo';
+ this.initData();
+ }
+
+ disconnectedCallback() {
+ if (this.saveTimer) clearTimeout(this.saveTimer);
+ if (this._boundPointerMove) document.removeEventListener('pointermove', this._boundPointerMove);
+ if (this._boundPointerUp) document.removeEventListener('pointerup', this._boundPointerUp);
+ if (this._boundKeyDown) document.removeEventListener('keydown', this._boundKeyDown);
+ }
+
+ // ── Data init ──
+
+ private async initData() {
+ try {
+ const res = await fetch(`${this.basePath}api/campaign-workflows`);
+ if (res.ok) {
+ const data = await res.json();
+ this.workflows = data.results || [];
+ if (this.workflows.length > 0) {
+ this.loadWorkflow(this.workflows[0]);
+ }
+ }
+ } catch {
+ console.warn('[CampaignWorkflow] Failed to load workflows');
+ }
+ this.render();
+ requestAnimationFrame(() => this.fitView());
+ }
+
+ private loadWorkflow(wf: CampaignWorkflow) {
+ this.currentWorkflowId = wf.id;
+ this.workflowName = wf.name;
+ this.workflowEnabled = wf.enabled;
+ this.nodes = wf.nodes.map(n => ({ ...n, position: { ...n.position } }));
+ this.edges = wf.edges.map(e => ({ ...e }));
+ this.selectedNodeId = null;
+ this.configOpen = false;
+ this.execLog = [];
+ }
+
+ // ── Persistence ──
+
+ private scheduleSave() {
+ this.saveIndicator = 'Saving...';
+ this.updateSaveIndicator();
+ if (this.saveTimer) clearTimeout(this.saveTimer);
+ this.saveTimer = setTimeout(() => { this.executeSave(); this.saveTimer = null; }, 1500);
+ }
+
+ private async executeSave() {
+ if (!this.currentWorkflowId) return;
+ try {
+ await fetch(`${this.basePath}api/campaign-workflows/${this.currentWorkflowId}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ name: this.workflowName,
+ enabled: this.workflowEnabled,
+ nodes: this.nodes,
+ edges: this.edges,
+ }),
+ });
+ this.saveIndicator = 'Saved';
+ } catch {
+ this.saveIndicator = 'Save failed';
+ }
+ this.updateSaveIndicator();
+ setTimeout(() => { this.saveIndicator = ''; this.updateSaveIndicator(); }, 2000);
+ }
+
+ private async createWorkflow() {
+ try {
+ const res = await fetch(`${this.basePath}api/campaign-workflows`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name: 'New Campaign Workflow' }),
+ });
+ if (res.ok) {
+ const wf = await res.json();
+ this.workflows.push(wf);
+ this.loadWorkflow(wf);
+ this.render();
+ requestAnimationFrame(() => this.fitView());
+ }
+ } catch {
+ console.error('[CampaignWorkflow] Failed to create workflow');
+ }
+ }
+
+ private async deleteWorkflow() {
+ if (!this.currentWorkflowId) return;
+ try {
+ await fetch(`${this.basePath}api/campaign-workflows/${this.currentWorkflowId}`, { method: 'DELETE' });
+ this.workflows = this.workflows.filter(w => w.id !== this.currentWorkflowId);
+ if (this.workflows.length > 0) {
+ this.loadWorkflow(this.workflows[0]);
+ } else {
+ this.currentWorkflowId = '';
+ this.nodes = [];
+ this.edges = [];
+ this.workflowName = '';
+ }
+ this.render();
+ } catch {
+ console.error('[CampaignWorkflow] Failed to delete workflow');
+ }
+ }
+
+ // ── Canvas transform ──
+
+ private updateCanvasTransform() {
+ const g = this.shadow.getElementById('canvas-transform');
+ if (g) g.setAttribute('transform', `translate(${this.canvasPanX},${this.canvasPanY}) scale(${this.canvasZoom})`);
+ this.updateZoomDisplay();
+ }
+
+ private updateZoomDisplay() {
+ const el = this.shadow.getElementById('zoom-level');
+ if (el) el.textContent = `${Math.round(this.canvasZoom * 100)}%`;
+ }
+
+ private updateSaveIndicator() {
+ const el = this.shadow.getElementById('save-indicator');
+ if (el) el.textContent = this.saveIndicator;
+ }
+
+ private fitView() {
+ const svg = this.shadow.getElementById('cw-svg') as SVGSVGElement | null;
+ if (!svg || this.nodes.length === 0) return;
+ const rect = svg.getBoundingClientRect();
+ if (rect.width === 0 || rect.height === 0) return;
+
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
+ for (const n of this.nodes) {
+ minX = Math.min(minX, n.position.x);
+ minY = Math.min(minY, n.position.y);
+ maxX = Math.max(maxX, n.position.x + NODE_WIDTH);
+ maxY = Math.max(maxY, n.position.y + NODE_HEIGHT);
+ }
+
+ const pad = 60;
+ const contentW = maxX - minX + pad * 2;
+ const contentH = maxY - minY + pad * 2;
+ const scaleX = rect.width / contentW;
+ const scaleY = rect.height / contentH;
+ this.canvasZoom = Math.min(scaleX, scaleY, 1.5);
+ this.canvasPanX = (rect.width - contentW * this.canvasZoom) / 2 - (minX - pad) * this.canvasZoom;
+ this.canvasPanY = (rect.height - contentH * this.canvasZoom) / 2 - (minY - pad) * this.canvasZoom;
+ this.updateCanvasTransform();
+ }
+
+ private zoomAt(screenX: number, screenY: number, factor: number) {
+ const oldZoom = this.canvasZoom;
+ const newZoom = Math.max(0.1, Math.min(4, oldZoom * factor));
+ this.canvasPanX = screenX - (screenX - this.canvasPanX) * (newZoom / oldZoom);
+ this.canvasPanY = screenY - (screenY - this.canvasPanY) * (newZoom / oldZoom);
+ this.canvasZoom = newZoom;
+ this.updateCanvasTransform();
+ }
+
+ // ── Rendering ──
+
+ private render() {
+ const paletteGroups: CampaignWorkflowNodeCategory[] = ['trigger', 'delay', 'condition', 'action'];
+
+ this.shadow.innerHTML = `
+
+
+
+
+
+
+ ${paletteGroups.map(cat => `
+
+
${cat}s
+ ${CAMPAIGN_NODE_CATALOG.filter(n => n.category === cat).map(n => `
+
+ ${n.icon}
+ ${esc(n.label)}
+
+ `).join('')}
+
+ `).join('')}
+
+
+
+
+
+
+ ${Math.round(this.canvasZoom * 100)}%
+
+
+
+
+
+
+ ${this.renderConfigPanel()}
+
+
+
+ `;
+
+ this.attachEventListeners();
+ }
+
+ private renderAllNodes(): string {
+ return this.nodes.map(node => this.renderNode(node)).join('');
+ }
+
+ private renderNode(node: CampaignWorkflowNode): string {
+ const def = getNodeDef(node.type);
+ if (!def) return '';
+ const catColor = CATEGORY_COLORS[def.category];
+ const status = node.runtimeStatus || 'idle';
+ const isSelected = node.id === this.selectedNodeId;
+
+ // Ports
+ let portsHtml = '';
+ for (const inp of def.inputs) {
+ const y = getPortY(node, inp.name, 'input');
+ const x = node.position.x;
+ const color = PORT_COLORS[inp.type] || '#6b7280';
+ portsHtml += `
+
+
+
+ `;
+ }
+ for (const out of def.outputs) {
+ const y = getPortY(node, out.name, 'output');
+ const x = node.position.x + NODE_WIDTH;
+ const color = PORT_COLORS[out.type] || '#6b7280';
+ portsHtml += `
+
+
+
+ `;
+ }
+
+ // Port labels
+ let portLabelHtml = '';
+ for (const inp of def.inputs) {
+ const y = getPortY(node, inp.name, 'input');
+ portLabelHtml += `${inp.name}`;
+ }
+ for (const out of def.outputs) {
+ const y = getPortY(node, out.name, 'output');
+ portLabelHtml += `${out.name}`;
+ }
+
+ return `
+
+
+
+
+
+
+ ${def.inputs.map(p => `${p.name}`).join('')}
+
+
+ ${def.outputs.map(p => `${p.name}`).join('')}
+
+
+
+
+ ${portsHtml}
+ ${portLabelHtml}
+ `;
+ }
+
+ private renderAllEdges(): string {
+ return this.edges.map(edge => {
+ const fromNode = this.nodes.find(n => n.id === edge.fromNode);
+ const toNode = this.nodes.find(n => n.id === edge.toNode);
+ if (!fromNode || !toNode) return '';
+
+ const x1 = getPortX(fromNode, edge.fromPort, 'output');
+ const y1 = getPortY(fromNode, edge.fromPort, 'output');
+ const x2 = getPortX(toNode, edge.toPort, 'input');
+ const y2 = getPortY(toNode, edge.toPort, 'input');
+
+ const fromDef = getNodeDef(fromNode.type);
+ const outPort = fromDef?.outputs.find(p => p.name === edge.fromPort);
+ const color = outPort ? (PORT_COLORS[outPort.type] || '#6b7280') : '#6b7280';
+ const d = bezierPath(x1, y1, x2, y2);
+
+ return `
+
+
+
+ `;
+ }).join('');
+ }
+
+ private renderConfigPanel(): string {
+ if (!this.selectedNodeId) {
+ return `
+
+
+
Click a node to configure it.
+
`;
+ }
+
+ const node = this.nodes.find(n => n.id === this.selectedNodeId);
+ if (!node) return '';
+ const def = getNodeDef(node.type);
+ if (!def) return '';
+
+ const fieldsHtml = def.configSchema.map(field => {
+ const val = node.config[field.key] ?? '';
+ if (field.type === 'select') {
+ const options = (field.options || []).map(o =>
+ ``
+ ).join('');
+ return `
+
+
+
+
`;
+ }
+ if (field.type === 'textarea') {
+ return `
+
+
+
+
`;
+ }
+ return `
+
+
+
+
`;
+ }).join('');
+
+ const logHtml = this.execLog.filter(e => e.nodeId === this.selectedNodeId).map(e => `
+
+
+ ${esc(e.message)} (${e.durationMs}ms)
+
+ `).join('');
+
+ return `
+
+
+
+
+
+
+ ${fieldsHtml}
+ ${logHtml ? `
` : ''}
+
+
`;
+ }
+
+ // ── Redraw helpers ──
+
+ private drawCanvasContent() {
+ const edgeLayer = this.shadow.getElementById('edge-layer');
+ const nodeLayer = this.shadow.getElementById('node-layer');
+ const wireLayer = this.shadow.getElementById('wire-layer');
+ if (!edgeLayer || !nodeLayer) return;
+ edgeLayer.innerHTML = this.renderAllEdges();
+ nodeLayer.innerHTML = this.renderAllNodes();
+ if (wireLayer) wireLayer.innerHTML = '';
+ }
+
+ private redrawEdges() {
+ const edgeLayer = this.shadow.getElementById('edge-layer');
+ if (edgeLayer) edgeLayer.innerHTML = this.renderAllEdges();
+ }
+
+ private updateNodePosition(node: CampaignWorkflowNode) {
+ const nodeLayer = this.shadow.getElementById('node-layer');
+ if (!nodeLayer) return;
+ const g = nodeLayer.querySelector(`[data-node-id="${node.id}"]`) as SVGGElement | null;
+ if (!g) return;
+ const fo = g.querySelector('foreignObject');
+ if (fo) {
+ fo.setAttribute('x', String(node.position.x));
+ fo.setAttribute('y', String(node.position.y));
+ }
+ const def = getNodeDef(node.type);
+ if (!def) return;
+ const portGroups = g.querySelectorAll('.cw-port-group');
+ portGroups.forEach(pg => {
+ const portName = (pg as HTMLElement).dataset.portName!;
+ const dir = (pg as HTMLElement).dataset.portDir as 'input' | 'output';
+ const x = dir === 'input' ? node.position.x : node.position.x + NODE_WIDTH;
+ const ports = dir === 'input' ? def.inputs : def.outputs;
+ const idx = ports.findIndex(p => p.name === portName);
+ const spacing = NODE_HEIGHT / (ports.length + 1);
+ const y = node.position.y + spacing * (idx + 1);
+ pg.querySelectorAll('circle').forEach(c => {
+ c.setAttribute('cx', String(x));
+ c.setAttribute('cy', String(y));
+ });
+ });
+ const labels = g.querySelectorAll('text');
+ let labelIdx = 0;
+ for (const inp of def.inputs) {
+ if (labels[labelIdx]) {
+ const y = getPortY(node, inp.name, 'input');
+ labels[labelIdx].setAttribute('x', String(node.position.x + 14));
+ labels[labelIdx].setAttribute('y', String(y + 4));
+ }
+ labelIdx++;
+ }
+ for (const out of def.outputs) {
+ if (labels[labelIdx]) {
+ const y = getPortY(node, out.name, 'output');
+ labels[labelIdx].setAttribute('x', String(node.position.x + NODE_WIDTH - 14));
+ labels[labelIdx].setAttribute('y', String(y + 4));
+ }
+ labelIdx++;
+ }
+ }
+
+ private refreshConfigPanel() {
+ const panel = this.shadow.getElementById('config-panel');
+ if (!panel) return;
+ panel.className = `cw-config ${this.configOpen ? 'open' : ''}`;
+ panel.innerHTML = this.renderConfigPanel();
+ this.attachConfigListeners();
+ }
+
+ // ── Node operations ──
+
+ private addNode(type: string, x: number, y: number) {
+ const def = getNodeDef(type);
+ if (!def) return;
+ const id = `cn-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
+ const node: CampaignWorkflowNode = {
+ id,
+ type: def.type,
+ label: def.label,
+ position: { x, y },
+ config: {},
+ };
+ this.nodes.push(node);
+ this.drawCanvasContent();
+ this.selectNode(id);
+ this.scheduleSave();
+ }
+
+ private deleteNode(nodeId: string) {
+ this.nodes = this.nodes.filter(n => n.id !== nodeId);
+ this.edges = this.edges.filter(e => e.fromNode !== nodeId && e.toNode !== nodeId);
+ if (this.selectedNodeId === nodeId) {
+ this.selectedNodeId = null;
+ this.configOpen = false;
+ }
+ this.drawCanvasContent();
+ this.refreshConfigPanel();
+ this.scheduleSave();
+ }
+
+ private selectNode(nodeId: string) {
+ this.selectedNodeId = nodeId;
+ this.configOpen = true;
+ const nodeLayer = this.shadow.getElementById('node-layer');
+ if (nodeLayer) {
+ nodeLayer.querySelectorAll('.cw-node').forEach(g => {
+ g.classList.toggle('selected', g.getAttribute('data-node-id') === nodeId);
+ });
+ }
+ this.refreshConfigPanel();
+ }
+
+ // ── Wiring ──
+
+ private enterWiring(nodeId: string, portName: string, dir: 'input' | 'output') {
+ if (dir !== 'output') return;
+ this.wiringActive = true;
+ this.wiringSourceNodeId = nodeId;
+ this.wiringSourcePortName = portName;
+ this.wiringSourceDir = dir;
+ const canvas = this.shadow.getElementById('cw-canvas');
+ if (canvas) canvas.classList.add('wiring');
+ }
+
+ private cancelWiring() {
+ this.wiringActive = false;
+ this.wiringSourceNodeId = null;
+ this.wiringSourcePortName = null;
+ this.wiringSourceDir = null;
+ const canvas = this.shadow.getElementById('cw-canvas');
+ if (canvas) canvas.classList.remove('wiring');
+ const wireLayer = this.shadow.getElementById('wire-layer');
+ if (wireLayer) wireLayer.innerHTML = '';
+ }
+
+ private completeWiring(targetNodeId: string, targetPortName: string, targetDir: 'input' | 'output') {
+ if (!this.wiringSourceNodeId || !this.wiringSourcePortName) { this.cancelWiring(); return; }
+ if (targetDir !== 'input') { this.cancelWiring(); return; }
+ if (targetNodeId === this.wiringSourceNodeId) { this.cancelWiring(); return; }
+
+ const exists = this.edges.some(e =>
+ e.fromNode === this.wiringSourceNodeId && e.fromPort === this.wiringSourcePortName &&
+ e.toNode === targetNodeId && e.toPort === targetPortName
+ );
+ if (exists) { this.cancelWiring(); return; }
+
+ const edgeId = `ce-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
+ this.edges.push({
+ id: edgeId,
+ fromNode: this.wiringSourceNodeId,
+ fromPort: this.wiringSourcePortName,
+ toNode: targetNodeId,
+ toPort: targetPortName,
+ });
+
+ this.cancelWiring();
+ this.drawCanvasContent();
+ this.scheduleSave();
+ }
+
+ private updateWiringTempLine() {
+ const svg = this.shadow.getElementById('cw-svg') as SVGSVGElement | null;
+ const wireLayer = this.shadow.getElementById('wire-layer');
+ if (!svg || !wireLayer || !this.wiringSourceNodeId || !this.wiringSourcePortName) return;
+
+ const sourceNode = this.nodes.find(n => n.id === this.wiringSourceNodeId);
+ if (!sourceNode) return;
+
+ const x1 = getPortX(sourceNode, this.wiringSourcePortName!, 'output');
+ const y1 = getPortY(sourceNode, this.wiringSourcePortName!, 'output');
+
+ const rect = svg.getBoundingClientRect();
+ const x2 = (this.wiringPointerX - rect.left - this.canvasPanX) / this.canvasZoom;
+ const y2 = (this.wiringPointerY - rect.top - this.canvasPanY) / this.canvasZoom;
+
+ const d = bezierPath(x1, y1, x2, y2);
+ wireLayer.innerHTML = ``;
+ }
+
+ // ── Execution ──
+
+ private async runWorkflow() {
+ if (!this.currentWorkflowId) return;
+ for (const n of this.nodes) {
+ n.runtimeStatus = 'running';
+ }
+ this.drawCanvasContent();
+
+ try {
+ const res = await fetch(`${this.basePath}api/campaign-workflows/${this.currentWorkflowId}/run`, { method: 'POST' });
+ const data = await res.json();
+ this.execLog = data.results || [];
+
+ for (const n of this.nodes) {
+ const logEntry = this.execLog.find(e => e.nodeId === n.id);
+ n.runtimeStatus = logEntry ? (logEntry.status as 'success' | 'error') : 'idle';
+ n.runtimeMessage = logEntry?.message;
+ }
+ } catch {
+ for (const n of this.nodes) {
+ n.runtimeStatus = 'error';
+ }
+ }
+
+ this.drawCanvasContent();
+ if (this.selectedNodeId) this.refreshConfigPanel();
+
+ setTimeout(() => {
+ for (const n of this.nodes) {
+ n.runtimeStatus = 'idle';
+ n.runtimeMessage = undefined;
+ }
+ this.drawCanvasContent();
+ }, 5000);
+ }
+
+ // ── Event listeners ──
+
+ private attachEventListeners() {
+ const canvas = this.shadow.getElementById('cw-canvas')!;
+ const svg = this.shadow.getElementById('cw-svg')!;
+ const palette = this.shadow.getElementById('palette')!;
+
+ // Toolbar
+ this.shadow.getElementById('wf-name')?.addEventListener('input', (e) => {
+ this.workflowName = (e.target as HTMLInputElement).value;
+ this.scheduleSave();
+ });
+
+ this.shadow.getElementById('wf-enabled')?.addEventListener('change', (e) => {
+ this.workflowEnabled = (e.target as HTMLInputElement).checked;
+ this.scheduleSave();
+ });
+
+ this.shadow.getElementById('btn-run')?.addEventListener('click', () => this.runWorkflow());
+ this.shadow.getElementById('btn-new')?.addEventListener('click', () => this.createWorkflow());
+ this.shadow.getElementById('btn-delete')?.addEventListener('click', () => this.deleteWorkflow());
+
+ this.shadow.getElementById('workflow-select')?.addEventListener('change', (e) => {
+ const id = (e.target as HTMLSelectElement).value;
+ const wf = this.workflows.find(w => w.id === id);
+ if (wf) {
+ this.loadWorkflow(wf);
+ this.drawCanvasContent();
+ this.refreshConfigPanel();
+ requestAnimationFrame(() => this.fitView());
+ }
+ });
+
+ // Zoom controls
+ this.shadow.getElementById('zoom-in')?.addEventListener('click', () => {
+ const rect = svg.getBoundingClientRect();
+ this.zoomAt(rect.width / 2, rect.height / 2, 1.2);
+ });
+ this.shadow.getElementById('zoom-out')?.addEventListener('click', () => {
+ const rect = svg.getBoundingClientRect();
+ this.zoomAt(rect.width / 2, rect.height / 2, 0.8);
+ });
+ this.shadow.getElementById('zoom-fit')?.addEventListener('click', () => this.fitView());
+
+ // Canvas mouse wheel
+ canvas.addEventListener('wheel', (e: WheelEvent) => {
+ e.preventDefault();
+ const rect = svg.getBoundingClientRect();
+ const factor = e.deltaY < 0 ? 1.1 : 0.9;
+ this.zoomAt(e.clientX - rect.left, e.clientY - rect.top, factor);
+ }, { passive: false });
+
+ // Palette drag
+ palette.querySelectorAll('.cw-palette__card').forEach(card => {
+ card.addEventListener('dragstart', (e: Event) => {
+ const de = e as DragEvent;
+ const type = (card as HTMLElement).dataset.nodeType!;
+ de.dataTransfer?.setData('text/plain', type);
+ });
+ });
+
+ // Canvas drop
+ canvas.addEventListener('dragover', (e: DragEvent) => { e.preventDefault(); });
+ canvas.addEventListener('drop', (e: DragEvent) => {
+ e.preventDefault();
+ const type = e.dataTransfer?.getData('text/plain');
+ if (!type) return;
+ const rect = svg.getBoundingClientRect();
+ const x = (e.clientX - rect.left - this.canvasPanX) / this.canvasZoom;
+ const y = (e.clientY - rect.top - this.canvasPanY) / this.canvasZoom;
+ this.addNode(type, x - NODE_WIDTH / 2, y - NODE_HEIGHT / 2);
+ });
+
+ // SVG pointer events
+ svg.addEventListener('pointerdown', (e: PointerEvent) => this.handlePointerDown(e));
+
+ // Global move/up
+ this._boundPointerMove = (e: PointerEvent) => this.handlePointerMove(e);
+ this._boundPointerUp = (e: PointerEvent) => this.handlePointerUp(e);
+ this._boundKeyDown = (e: KeyboardEvent) => this.handleKeyDown(e);
+ document.addEventListener('pointermove', this._boundPointerMove);
+ document.addEventListener('pointerup', this._boundPointerUp);
+ document.addEventListener('keydown', this._boundKeyDown);
+
+ // Config panel
+ this.attachConfigListeners();
+ }
+
+ private attachConfigListeners() {
+ this.shadow.getElementById('config-close')?.addEventListener('click', () => {
+ this.configOpen = false;
+ this.selectedNodeId = null;
+ const panel = this.shadow.getElementById('config-panel');
+ if (panel) panel.className = 'cw-config';
+ this.drawCanvasContent();
+ });
+
+ this.shadow.getElementById('config-label')?.addEventListener('input', (e) => {
+ const node = this.nodes.find(n => n.id === this.selectedNodeId);
+ if (node) {
+ node.label = (e.target as HTMLInputElement).value;
+ this.drawCanvasContent();
+ this.scheduleSave();
+ }
+ });
+
+ this.shadow.getElementById('config-delete-node')?.addEventListener('click', () => {
+ if (this.selectedNodeId) this.deleteNode(this.selectedNodeId);
+ });
+
+ const configPanel = this.shadow.getElementById('config-panel');
+ if (configPanel) {
+ configPanel.querySelectorAll('[data-config-key]').forEach(el => {
+ el.addEventListener('input', (e) => {
+ const key = (el as HTMLElement).dataset.configKey!;
+ const node = this.nodes.find(n => n.id === this.selectedNodeId);
+ if (node) {
+ node.config[key] = (e.target as HTMLInputElement).value;
+ this.scheduleSave();
+ }
+ });
+ el.addEventListener('change', (e) => {
+ const key = (el as HTMLElement).dataset.configKey!;
+ const node = this.nodes.find(n => n.id === this.selectedNodeId);
+ if (node) {
+ node.config[key] = (e.target as HTMLSelectElement).value;
+ this.scheduleSave();
+ }
+ });
+ });
+ }
+ }
+
+ private handlePointerDown(e: PointerEvent) {
+ const target = e.target as Element;
+
+ // Port click — start/complete wiring
+ const portGroup = target.closest('.cw-port-group') as SVGElement | null;
+ if (portGroup) {
+ e.stopPropagation();
+ const nodeId = portGroup.dataset.nodeId!;
+ const portName = portGroup.dataset.portName!;
+ const dir = portGroup.dataset.portDir as 'input' | 'output';
+
+ if (this.wiringActive) {
+ this.completeWiring(nodeId, portName, dir);
+ } else {
+ this.enterWiring(nodeId, portName, dir);
+ }
+ return;
+ }
+
+ // Edge click — delete
+ const edgeGroup = target.closest('.cw-edge-group') as SVGElement | null;
+ if (edgeGroup) {
+ e.stopPropagation();
+ const edgeId = edgeGroup.dataset.edgeId!;
+ this.edges = this.edges.filter(ed => ed.id !== edgeId);
+ this.redrawEdges();
+ this.scheduleSave();
+ return;
+ }
+
+ // Node click — select + start drag
+ const nodeGroup = target.closest('.cw-node') as SVGElement | null;
+ if (nodeGroup) {
+ e.stopPropagation();
+ if (this.wiringActive) {
+ this.cancelWiring();
+ return;
+ }
+ const nodeId = nodeGroup.dataset.nodeId!;
+ this.selectNode(nodeId);
+
+ const node = this.nodes.find(n => n.id === nodeId);
+ if (node) {
+ this.draggingNodeId = nodeId;
+ this.dragStartX = e.clientX;
+ this.dragStartY = e.clientY;
+ this.dragNodeStartX = node.position.x;
+ this.dragNodeStartY = node.position.y;
+ }
+ return;
+ }
+
+ // Canvas click — pan or deselect
+ if (this.wiringActive) {
+ this.cancelWiring();
+ return;
+ }
+
+ this.isPanning = true;
+ this.panStartX = e.clientX;
+ this.panStartY = e.clientY;
+ this.panStartPanX = this.canvasPanX;
+ this.panStartPanY = this.canvasPanY;
+ const canvas = this.shadow.getElementById('cw-canvas');
+ if (canvas) canvas.classList.add('grabbing');
+
+ if (this.selectedNodeId) {
+ this.selectedNodeId = null;
+ this.configOpen = false;
+ this.drawCanvasContent();
+ this.refreshConfigPanel();
+ }
+ }
+
+ private handlePointerMove(e: PointerEvent) {
+ if (this.wiringActive) {
+ this.wiringPointerX = e.clientX;
+ this.wiringPointerY = e.clientY;
+ this.updateWiringTempLine();
+ return;
+ }
+
+ if (this.draggingNodeId) {
+ const node = this.nodes.find(n => n.id === this.draggingNodeId);
+ if (node) {
+ const dx = (e.clientX - this.dragStartX) / this.canvasZoom;
+ const dy = (e.clientY - this.dragStartY) / this.canvasZoom;
+ node.position.x = this.dragNodeStartX + dx;
+ node.position.y = this.dragNodeStartY + dy;
+ this.updateNodePosition(node);
+ this.redrawEdges();
+ }
+ return;
+ }
+
+ if (this.isPanning) {
+ this.canvasPanX = this.panStartPanX + (e.clientX - this.panStartX);
+ this.canvasPanY = this.panStartPanY + (e.clientY - this.panStartY);
+ this.updateCanvasTransform();
+ }
+ }
+
+ private handlePointerUp(_e: PointerEvent) {
+ if (this.draggingNodeId) {
+ this.draggingNodeId = null;
+ this.scheduleSave();
+ }
+ if (this.isPanning) {
+ this.isPanning = false;
+ const canvas = this.shadow.getElementById('cw-canvas');
+ if (canvas) canvas.classList.remove('grabbing');
+ }
+ }
+
+ private handleKeyDown(e: KeyboardEvent) {
+ if (e.key === 'Escape') {
+ if (this.wiringActive) this.cancelWiring();
+ }
+ if ((e.key === 'Delete' || e.key === 'Backspace') && this.selectedNodeId) {
+ if ((e.target as Element)?.tagName === 'INPUT' || (e.target as Element)?.tagName === 'TEXTAREA') return;
+ this.deleteNode(this.selectedNodeId);
+ }
+ }
+}
+
+customElements.define('folk-campaign-workflow', FolkCampaignWorkflow);
diff --git a/modules/rsocials/local-first-client.ts b/modules/rsocials/local-first-client.ts
index 10ba601..cd378e3 100644
--- a/modules/rsocials/local-first-client.ts
+++ b/modules/rsocials/local-first-client.ts
@@ -11,7 +11,7 @@ import { EncryptedDocStore } from '../../shared/local-first/storage';
import { DocSyncManager } from '../../shared/local-first/sync';
import { DocCrypto } from '../../shared/local-first/crypto';
import { socialsSchema, socialsDocId } from './schemas';
-import type { SocialsDoc, ThreadData, Campaign, CampaignFlow, CampaignPlannerNode, CampaignEdge } from './schemas';
+import type { SocialsDoc, ThreadData, Campaign, CampaignFlow, CampaignPlannerNode, CampaignEdge, CampaignWorkflow, CampaignWorkflowNode, CampaignWorkflowEdge } from './schemas';
export class SocialsLocalFirstClient {
#space: string;
@@ -193,6 +193,49 @@ export class SocialsLocalFirstClient {
});
}
+ // ── Campaign workflow reads ──
+
+ listCampaignWorkflows(): CampaignWorkflow[] {
+ const doc = this.getDoc();
+ if (!doc?.campaignWorkflows) return [];
+ return Object.values(doc.campaignWorkflows).sort((a, b) => b.updatedAt - a.updatedAt);
+ }
+
+ getCampaignWorkflow(id: string): CampaignWorkflow | undefined {
+ const doc = this.getDoc();
+ return doc?.campaignWorkflows?.[id];
+ }
+
+ // ── Campaign workflow writes ──
+
+ saveCampaignWorkflow(wf: CampaignWorkflow): void {
+ const docId = socialsDocId(this.#space) as DocumentId;
+ this.#sync.change(docId, `Save campaign workflow ${wf.name || wf.id}`, (d) => {
+ if (!d.campaignWorkflows) d.campaignWorkflows = {} as any;
+ wf.updatedAt = Date.now();
+ if (!wf.createdAt) wf.createdAt = Date.now();
+ d.campaignWorkflows[wf.id] = wf;
+ });
+ }
+
+ updateCampaignWorkflowNodesEdges(wfId: string, nodes: CampaignWorkflowNode[], edges: CampaignWorkflowEdge[]): void {
+ const docId = socialsDocId(this.#space) as DocumentId;
+ this.#sync.change(docId, `Update campaign workflow nodes ${wfId}`, (d) => {
+ if (d.campaignWorkflows?.[wfId]) {
+ d.campaignWorkflows[wfId].nodes = nodes;
+ d.campaignWorkflows[wfId].edges = edges;
+ d.campaignWorkflows[wfId].updatedAt = Date.now();
+ }
+ });
+ }
+
+ deleteCampaignWorkflow(id: string): void {
+ const docId = socialsDocId(this.#space) as DocumentId;
+ this.#sync.change(docId, `Delete campaign workflow ${id}`, (d) => {
+ if (d.campaignWorkflows?.[id]) delete d.campaignWorkflows[id];
+ });
+ }
+
// ── Events ──
onChange(cb: (doc: SocialsDoc) => void): () => void {
diff --git a/modules/rsocials/mod.ts b/modules/rsocials/mod.ts
index fad2969..8a28daf 100644
--- a/modules/rsocials/mod.ts
+++ b/modules/rsocials/mod.ts
@@ -18,8 +18,8 @@ import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module";
import type { SyncServer } from "../../server/local-first/sync-server";
import { renderLanding } from "./landing";
-import { MYCOFI_CAMPAIGN, buildDemoCampaignFlow } from "./campaign-data";
-import { socialsSchema, socialsDocId, type SocialsDoc, type ThreadData } from "./schemas";
+import { MYCOFI_CAMPAIGN, buildDemoCampaignFlow, buildDemoCampaignWorkflow } from "./campaign-data";
+import { socialsSchema, socialsDocId, type SocialsDoc, type ThreadData, type CampaignWorkflow, type CampaignWorkflowNode, type CampaignWorkflowEdge } from "./schemas";
import {
generateImageFromPrompt,
downloadAndSaveImage,
@@ -54,6 +54,7 @@ function ensureDoc(space: string): SocialsDoc {
d.campaigns = {};
d.campaignFlows = {};
d.activeFlowId = '';
+ d.campaignWorkflows = {};
});
_syncServer!.setDoc(docId, doc);
}
@@ -130,6 +131,16 @@ function seedTemplateSocials(space: string): void {
});
}
+ // Seed campaign workflow if empty
+ if (Object.keys(doc.campaignWorkflows || {}).length === 0) {
+ const docId = socialsDocId(space);
+ const wf = buildDemoCampaignWorkflow();
+ _syncServer.changeDoc(docId, "seed campaign workflow", (d) => {
+ if (!d.campaignWorkflows) d.campaignWorkflows = {} as any;
+ d.campaignWorkflows[wf.id] = wf;
+ });
+ }
+
// Seed a sample thread if empty
if (Object.keys(doc.threads || {}).length === 0) {
const docId = socialsDocId(space);
@@ -504,6 +515,197 @@ routes.put("/api/newsletter/campaigns/:id/status", async (c) => {
return c.json(data, res.status as any);
});
+// ── Campaign Workflow CRUD API ──
+
+routes.get("/api/campaign-workflows", (c) => {
+ const space = c.req.param("space") || "demo";
+ const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
+ const doc = ensureDoc(dataSpace);
+ const workflows = Object.values(doc.campaignWorkflows || {});
+ workflows.sort((a, b) => a.name.localeCompare(b.name));
+ return c.json({ count: workflows.length, results: workflows });
+});
+
+routes.post("/api/campaign-workflows", async (c) => {
+ const space = c.req.param("space") || "demo";
+ const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
+ const body = await c.req.json();
+
+ const docId = socialsDocId(dataSpace);
+ ensureDoc(dataSpace);
+ const wfId = crypto.randomUUID();
+ const now = Date.now();
+
+ const workflow: CampaignWorkflow = {
+ id: wfId,
+ name: body.name || "New Campaign Workflow",
+ enabled: body.enabled !== false,
+ nodes: body.nodes || [],
+ edges: body.edges || [],
+ lastRunAt: null,
+ lastRunStatus: null,
+ runCount: 0,
+ createdAt: now,
+ updatedAt: now,
+ };
+
+ _syncServer!.changeDoc(docId, `create campaign workflow ${wfId}`, (d) => {
+ if (!d.campaignWorkflows) d.campaignWorkflows = {} as any;
+ (d.campaignWorkflows as any)[wfId] = workflow;
+ });
+
+ const updated = _syncServer!.getDoc(docId)!;
+ return c.json(updated.campaignWorkflows[wfId], 201);
+});
+
+routes.get("/api/campaign-workflows/:id", (c) => {
+ const space = c.req.param("space") || "demo";
+ const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
+ const id = c.req.param("id");
+ const doc = ensureDoc(dataSpace);
+
+ const wf = doc.campaignWorkflows?.[id];
+ if (!wf) return c.json({ error: "Campaign workflow not found" }, 404);
+ return c.json(wf);
+});
+
+routes.put("/api/campaign-workflows/:id", async (c) => {
+ const space = c.req.param("space") || "demo";
+ const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
+ const id = c.req.param("id");
+ const body = await c.req.json();
+
+ const docId = socialsDocId(dataSpace);
+ const doc = ensureDoc(dataSpace);
+ if (!doc.campaignWorkflows?.[id]) return c.json({ error: "Campaign workflow not found" }, 404);
+
+ _syncServer!.changeDoc(docId, `update campaign workflow ${id}`, (d) => {
+ const wf = d.campaignWorkflows[id];
+ if (!wf) return;
+ if (body.name !== undefined) wf.name = body.name;
+ if (body.enabled !== undefined) wf.enabled = body.enabled;
+ if (body.nodes !== undefined) {
+ while (wf.nodes.length > 0) wf.nodes.splice(0, 1);
+ for (const n of body.nodes) wf.nodes.push(n);
+ }
+ if (body.edges !== undefined) {
+ while (wf.edges.length > 0) wf.edges.splice(0, 1);
+ for (const e of body.edges) wf.edges.push(e);
+ }
+ wf.updatedAt = Date.now();
+ });
+
+ const updated = _syncServer!.getDoc(docId)!;
+ return c.json(updated.campaignWorkflows[id]);
+});
+
+routes.delete("/api/campaign-workflows/:id", (c) => {
+ const space = c.req.param("space") || "demo";
+ const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
+ const id = c.req.param("id");
+
+ const docId = socialsDocId(dataSpace);
+ const doc = ensureDoc(dataSpace);
+ if (!doc.campaignWorkflows?.[id]) return c.json({ error: "Campaign workflow not found" }, 404);
+
+ _syncServer!.changeDoc(docId, `delete campaign workflow ${id}`, (d) => {
+ delete d.campaignWorkflows[id];
+ });
+
+ return c.json({ ok: true });
+});
+
+// POST /api/campaign-workflows/:id/run — manual execute (stub)
+routes.post("/api/campaign-workflows/:id/run", async (c) => {
+ const space = c.req.param("space") || "demo";
+ const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
+ const id = c.req.param("id");
+
+ const doc = ensureDoc(dataSpace);
+ const wf = doc.campaignWorkflows?.[id];
+ if (!wf) return c.json({ error: "Campaign workflow not found" }, 404);
+
+ // Stub execution — topological walk, each node returns stub success
+ const results: { nodeId: string; status: string; message: string; durationMs: number }[] = [];
+ const sorted = topologicalSortCampaign(wf.nodes, wf.edges);
+
+ for (const node of sorted) {
+ const start = Date.now();
+ results.push({
+ nodeId: node.id,
+ status: 'success',
+ message: `[stub] ${node.label} executed`,
+ durationMs: Date.now() - start,
+ });
+ }
+
+ // Update run metadata
+ const docId = socialsDocId(dataSpace);
+ _syncServer!.changeDoc(docId, `run campaign workflow ${id}`, (d) => {
+ const w = d.campaignWorkflows[id];
+ if (!w) return;
+ w.lastRunAt = Date.now();
+ w.lastRunStatus = 'success';
+ w.runCount = (w.runCount || 0) + 1;
+ });
+
+ return c.json({ results });
+});
+
+// POST /api/campaign-workflows/webhook/:hookId — external webhook trigger
+routes.post("/api/campaign-workflows/webhook/:hookId", async (c) => {
+ const hookId = c.req.param("hookId");
+ const space = c.req.param("space") || "demo";
+ const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
+
+ const doc = ensureDoc(dataSpace);
+ // Find workflow containing a webhook-trigger node with this hookId
+ for (const wf of Object.values(doc.campaignWorkflows || {})) {
+ if (!wf.enabled) continue;
+ const hookNode = wf.nodes.find(n =>
+ n.type === 'webhook-trigger' && n.config.hookId === hookId
+ );
+ if (hookNode) {
+ return c.json({ ok: true, workflowId: wf.id, message: 'Webhook received (stub — execution not implemented yet)' });
+ }
+ }
+ return c.json({ error: "No matching webhook trigger found" }, 404);
+});
+
+function topologicalSortCampaign(nodes: CampaignWorkflowNode[], edges: CampaignWorkflowEdge[]): CampaignWorkflowNode[] {
+ const adj = new Map();
+ const inDegree = new Map();
+
+ for (const n of nodes) {
+ adj.set(n.id, []);
+ inDegree.set(n.id, 0);
+ }
+
+ for (const e of edges) {
+ adj.get(e.fromNode)?.push(e.toNode);
+ inDegree.set(e.toNode, (inDegree.get(e.toNode) || 0) + 1);
+ }
+
+ const queue: string[] = [];
+ for (const [id, deg] of inDegree) {
+ if (deg === 0) queue.push(id);
+ }
+
+ const sorted: CampaignWorkflowNode[] = [];
+ while (queue.length > 0) {
+ const id = queue.shift()!;
+ const node = nodes.find(n => n.id === id);
+ if (node) sorted.push(node);
+ for (const next of adj.get(id) || []) {
+ const d = (inDegree.get(next) || 1) - 1;
+ inDegree.set(next, d);
+ if (d === 0) queue.push(next);
+ }
+ }
+
+ return sorted;
+}
+
// ── Page routes (inject web components) ──
routes.get("/campaign", (c) => {
@@ -620,14 +822,14 @@ routes.get("/threads", (c) => {
routes.get("/campaigns", (c) => {
const space = c.req.param("space") || "demo";
return c.html(renderShell({
- title: `Campaign Planner — rSocials | rSpace`,
+ title: `Campaign Workflows — rSocials | rSpace`,
moduleId: "rsocials",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
- body: ``,
- styles: ``,
- scripts: ``,
+ body: ``,
+ styles: ``,
+ scripts: ``,
}));
});
diff --git a/modules/rsocials/schemas.ts b/modules/rsocials/schemas.ts
index 82ef91a..8592c4e 100644
--- a/modules/rsocials/schemas.ts
+++ b/modules/rsocials/schemas.ts
@@ -120,6 +120,241 @@ export interface CampaignFlow {
createdBy: string | null;
}
+// ── Campaign workflow (n8n-style) types ──
+
+export type CampaignWorkflowNodeType =
+ // Triggers
+ | 'campaign-start'
+ | 'schedule-trigger'
+ | 'webhook-trigger'
+ // Delays
+ | 'wait-duration'
+ | 'wait-approval'
+ // Conditions
+ | 'engagement-check'
+ | 'time-window'
+ // Actions
+ | 'post-to-platform'
+ | 'cross-post'
+ | 'publish-thread'
+ | 'send-newsletter'
+ | 'post-webhook';
+
+export type CampaignWorkflowNodeCategory = 'trigger' | 'delay' | 'condition' | 'action';
+
+export interface CampaignWorkflowNodePort {
+ name: string;
+ type: 'trigger' | 'data' | 'boolean';
+}
+
+export interface CampaignWorkflowNode {
+ id: string;
+ type: CampaignWorkflowNodeType;
+ label: string;
+ position: { x: number; y: number };
+ config: Record;
+ runtimeStatus?: 'idle' | 'running' | 'success' | 'error';
+ runtimeMessage?: string;
+ runtimeDurationMs?: number;
+}
+
+export interface CampaignWorkflowEdge {
+ id: string;
+ fromNode: string;
+ fromPort: string;
+ toNode: string;
+ toPort: string;
+}
+
+export interface CampaignWorkflow {
+ id: string;
+ name: string;
+ enabled: boolean;
+ nodes: CampaignWorkflowNode[];
+ edges: CampaignWorkflowEdge[];
+ lastRunAt: number | null;
+ lastRunStatus: 'success' | 'error' | null;
+ runCount: number;
+ createdAt: number;
+ updatedAt: number;
+}
+
+export interface CampaignWorkflowNodeDef {
+ type: CampaignWorkflowNodeType;
+ category: CampaignWorkflowNodeCategory;
+ label: string;
+ icon: string;
+ description: string;
+ inputs: CampaignWorkflowNodePort[];
+ outputs: CampaignWorkflowNodePort[];
+ configSchema: { key: string; label: string; type: 'text' | 'number' | 'select' | 'textarea' | 'cron'; options?: string[]; placeholder?: string }[];
+}
+
+export const CAMPAIGN_NODE_CATALOG: CampaignWorkflowNodeDef[] = [
+ // ── Triggers ──
+ {
+ type: 'campaign-start',
+ category: 'trigger',
+ label: 'Campaign Start',
+ icon: '\u25B6',
+ description: 'Manual kick-off for this campaign workflow',
+ inputs: [],
+ outputs: [{ name: 'trigger', type: 'trigger' }],
+ configSchema: [
+ { key: 'description', label: 'Description', type: 'text', placeholder: 'Launch campaign' },
+ ],
+ },
+ {
+ type: 'schedule-trigger',
+ category: 'trigger',
+ label: 'Schedule Trigger',
+ icon: '\u23F0',
+ description: 'Fire on a cron schedule',
+ inputs: [],
+ outputs: [{ name: 'trigger', type: 'trigger' }, { name: 'timestamp', type: 'data' }],
+ configSchema: [
+ { key: 'cronExpression', label: 'Cron Expression', type: 'cron', placeholder: '0 9 * * 1' },
+ { key: 'timezone', label: 'Timezone', type: 'text', placeholder: 'America/Vancouver' },
+ ],
+ },
+ {
+ type: 'webhook-trigger',
+ category: 'trigger',
+ label: 'Webhook Trigger',
+ icon: '\uD83D\uDD17',
+ description: 'Fire when an external webhook is received',
+ inputs: [],
+ outputs: [{ name: 'trigger', type: 'trigger' }, { name: 'payload', type: 'data' }],
+ configSchema: [
+ { key: 'hookId', label: 'Hook ID', type: 'text', placeholder: 'auto-generated' },
+ ],
+ },
+ // ── Delays ──
+ {
+ type: 'wait-duration',
+ category: 'delay',
+ label: 'Wait Duration',
+ icon: '\u23F3',
+ description: 'Wait for a specified amount of time',
+ inputs: [{ name: 'trigger', type: 'trigger' }],
+ outputs: [{ name: 'done', type: 'trigger' }],
+ configSchema: [
+ { key: 'amount', label: 'Amount', type: 'number', placeholder: '2' },
+ { key: 'unit', label: 'Unit', type: 'select', options: ['minutes', 'hours', 'days'] },
+ ],
+ },
+ {
+ type: 'wait-approval',
+ category: 'delay',
+ label: 'Wait for Approval',
+ icon: '\u270B',
+ description: 'Pause until a team member approves',
+ inputs: [{ name: 'trigger', type: 'trigger' }],
+ outputs: [{ name: 'approved', type: 'trigger' }, { name: 'rejected', type: 'trigger' }],
+ configSchema: [
+ { key: 'approver', label: 'Approver', type: 'text', placeholder: 'Team lead' },
+ { key: 'message', label: 'Approval Message', type: 'textarea', placeholder: 'Please review...' },
+ ],
+ },
+ // ── Conditions ──
+ {
+ type: 'engagement-check',
+ category: 'condition',
+ label: 'Engagement Check',
+ icon: '\uD83D\uDCC8',
+ description: 'Check if engagement exceeds a threshold',
+ inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'metrics', type: 'data' }],
+ outputs: [{ name: 'above', type: 'trigger' }, { name: 'below', type: 'trigger' }],
+ configSchema: [
+ { key: 'metric', label: 'Metric', type: 'select', options: ['likes', 'retweets', 'replies', 'impressions', 'clicks'] },
+ { key: 'threshold', label: 'Threshold', type: 'number', placeholder: '100' },
+ ],
+ },
+ {
+ type: 'time-window',
+ category: 'condition',
+ label: 'Time Window',
+ icon: '\uD83D\uDD50',
+ description: 'Check if current time is within posting hours',
+ inputs: [{ name: 'trigger', type: 'trigger' }],
+ outputs: [{ name: 'in-window', type: 'trigger' }, { name: 'outside', type: 'trigger' }],
+ configSchema: [
+ { key: 'startHour', label: 'Start Hour (0-23)', type: 'number', placeholder: '9' },
+ { key: 'endHour', label: 'End Hour (0-23)', type: 'number', placeholder: '17' },
+ { key: 'days', label: 'Days (1=Mon)', type: 'text', placeholder: '1,2,3,4,5' },
+ ],
+ },
+ // ── Actions ──
+ {
+ type: 'post-to-platform',
+ category: 'action',
+ label: 'Post to Platform',
+ icon: '\uD83D\uDCE4',
+ description: 'Publish a post to a social platform',
+ inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'content', type: 'data' }],
+ outputs: [{ name: 'done', type: 'trigger' }, { name: 'postId', type: 'data' }],
+ configSchema: [
+ { key: 'platform', label: 'Platform', type: 'select', options: ['X', 'LinkedIn', 'Instagram', 'Threads', 'Bluesky', 'YouTube'] },
+ { key: 'content', label: 'Post Content', type: 'textarea', placeholder: 'Your post text...' },
+ { key: 'hashtags', label: 'Hashtags', type: 'text', placeholder: '#launch #campaign' },
+ ],
+ },
+ {
+ type: 'cross-post',
+ category: 'action',
+ label: 'Cross-Post',
+ icon: '\uD83D\uDCE1',
+ description: 'Broadcast to multiple platforms at once',
+ inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'content', type: 'data' }],
+ outputs: [{ name: 'done', type: 'trigger' }, { name: 'results', type: 'data' }],
+ configSchema: [
+ { key: 'platforms', label: 'Platforms (comma-sep)', type: 'text', placeholder: 'X, LinkedIn, Threads' },
+ { key: 'content', label: 'Post Content', type: 'textarea', placeholder: 'Shared content...' },
+ { key: 'adaptPerPlatform', label: 'Adapt per platform', type: 'select', options: ['yes', 'no'] },
+ ],
+ },
+ {
+ type: 'publish-thread',
+ category: 'action',
+ label: 'Publish Thread',
+ icon: '\uD83E\uDDF5',
+ description: 'Publish a multi-tweet thread',
+ inputs: [{ name: 'trigger', type: 'trigger' }],
+ outputs: [{ name: 'done', type: 'trigger' }, { name: 'threadId', type: 'data' }],
+ configSchema: [
+ { key: 'platform', label: 'Platform', type: 'select', options: ['X', 'Bluesky', 'Threads'] },
+ { key: 'threadContent', label: 'Thread (--- between tweets)', type: 'textarea', placeholder: 'Tweet 1\n---\nTweet 2\n---\nTweet 3' },
+ ],
+ },
+ {
+ type: 'send-newsletter',
+ category: 'action',
+ label: 'Send Newsletter',
+ icon: '\uD83D\uDCE7',
+ description: 'Send an email campaign via Listmonk',
+ inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'content', type: 'data' }],
+ outputs: [{ name: 'done', type: 'trigger' }, { name: 'campaignId', type: 'data' }],
+ configSchema: [
+ { key: 'subject', label: 'Email Subject', type: 'text', placeholder: 'Newsletter: Campaign Update' },
+ { key: 'listId', label: 'List ID', type: 'text', placeholder: '1' },
+ { key: 'bodyTemplate', label: 'Body (HTML)', type: 'textarea', placeholder: 'Hello {{name}}
' },
+ ],
+ },
+ {
+ type: 'post-webhook',
+ category: 'action',
+ label: 'POST Webhook',
+ icon: '\uD83C\uDF10',
+ description: 'Send an HTTP POST to an external URL',
+ inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }],
+ outputs: [{ name: 'done', type: 'trigger' }, { name: 'response', type: 'data' }],
+ configSchema: [
+ { key: 'url', label: 'URL', type: 'text', placeholder: 'https://api.example.com/hook' },
+ { key: 'bodyTemplate', label: 'Body Template', type: 'textarea', placeholder: '{"event": "campaign_step"}' },
+ ],
+ },
+];
+
// ── Document root ──
export interface SocialsDoc {
@@ -134,6 +369,7 @@ export interface SocialsDoc {
campaigns: Record;
campaignFlows: Record;
activeFlowId: string;
+ campaignWorkflows: Record;
}
// ── Schema registration ──
@@ -141,12 +377,12 @@ export interface SocialsDoc {
export const socialsSchema: DocSchema = {
module: 'socials',
collection: 'data',
- version: 2,
+ version: 3,
init: (): SocialsDoc => ({
meta: {
module: 'socials',
collection: 'data',
- version: 2,
+ version: 3,
spaceSlug: '',
createdAt: Date.now(),
},
@@ -154,11 +390,13 @@ export const socialsSchema: DocSchema = {
campaigns: {},
campaignFlows: {},
activeFlowId: '',
+ campaignWorkflows: {},
}),
migrate: (doc: SocialsDoc, _fromVersion: number): SocialsDoc => {
if (!doc.campaignFlows) (doc as any).campaignFlows = {};
if (!doc.activeFlowId) (doc as any).activeFlowId = '';
- if (doc.meta) doc.meta.version = 2;
+ if (!doc.campaignWorkflows) (doc as any).campaignWorkflows = {};
+ if (doc.meta) doc.meta.version = 3;
return doc;
},
};
diff --git a/vite.config.ts b/vite.config.ts
index 380e4e1..7835943 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -819,6 +819,37 @@ export default defineConfig({
resolve(__dirname, "dist/modules/rsocials/campaign-planner.css"),
);
+ // Build campaign workflow builder component
+ await build({
+ configFile: false,
+ root: resolve(__dirname, "modules/rsocials/components"),
+ resolve: {
+ alias: {
+ "../schemas": resolve(__dirname, "modules/rsocials/schemas.ts"),
+ },
+ },
+ build: {
+ emptyOutDir: false,
+ outDir: resolve(__dirname, "dist/modules/rsocials"),
+ lib: {
+ entry: resolve(__dirname, "modules/rsocials/components/folk-campaign-workflow.ts"),
+ formats: ["es"],
+ fileName: () => "folk-campaign-workflow.js",
+ },
+ rollupOptions: {
+ output: {
+ entryFileNames: "folk-campaign-workflow.js",
+ },
+ },
+ },
+ });
+
+ // Copy campaign workflow CSS
+ copyFileSync(
+ resolve(__dirname, "modules/rsocials/components/campaign-workflow.css"),
+ resolve(__dirname, "dist/modules/rsocials/campaign-workflow.css"),
+ );
+
// Build newsletter manager component
await build({
configFile: false,