feat(rsocials): add Campaign Wizard with 5-step AI-guided creation flow

Progressive approval workflow: paste brief → AI extracts structure →
AI generates per-platform posts → review with per-post regen →
commit (saves campaign, creates threads, drafts newsletters, builds workflow DAG).

Includes MI integration for Cmd+K campaign creation and vite build entry.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-21 14:41:45 -07:00
parent f8ab716722
commit 5ad6c6ff77
9 changed files with 1790 additions and 8 deletions

View File

@ -290,10 +290,16 @@ export class MiActionExecutor {
}
const data = await res.json().catch(() => ({}));
const contentId = data.id || data._id || undefined;
const contentId = data.id || data.wizardId || data._id || undefined;
if (action.ref && contentId) {
refMap.set(action.ref, contentId);
}
// Navigate to wizard if requested (rsocials campaign creation)
if (action.body?.navigateToWizard && data.url) {
window.location.href = data.url;
}
return { action, ok: true, contentId };
} catch (e: any) {
return { action, ok: false, error: e.message };

View File

@ -28,6 +28,9 @@ export const MODULE_ROUTES: Record<string, Record<string, ModuleRouteEntry>> = {
thread: { method: "POST", path: "/:space/rforum/api/threads" },
post: { method: "POST", path: "/:space/rforum/api/posts" },
},
rsocials: {
campaign: { method: "POST", path: "/:space/rsocials/api/campaign/wizard" },
},
rflows: {
flow: { method: "POST", path: "/:space/rflows/api/flows" },
},

View File

@ -0,0 +1,419 @@
/**
* Campaign Wizard styles.
* Uses existing CSS variables from rsocials design system.
*/
/* ── Container ── */
.cw-container {
max-width: 960px;
margin: 2rem auto;
padding: 0 1.5rem;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
color: var(--rs-text-primary, #e1e1e1);
}
.cw-header {
margin-bottom: 2rem;
}
.cw-header h1 {
font-size: 1.6rem;
margin: 0 0 0.25rem;
}
.cw-header p {
color: var(--rs-text-secondary, #94a3b8);
margin: 0;
font-size: 0.9rem;
}
/* ── Step indicator ── */
.cw-steps {
display: flex;
align-items: center;
gap: 0;
margin-bottom: 2.5rem;
padding: 0;
list-style: none;
}
.cw-step {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.82rem;
color: var(--rs-text-muted, #64748b);
white-space: nowrap;
}
.cw-step__num {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 0.75rem;
border: 2px solid var(--rs-border, #333);
background: transparent;
transition: all 0.2s;
flex-shrink: 0;
}
.cw-step.active .cw-step__num {
border-color: var(--rs-accent, #14b8a6);
background: var(--rs-accent, #14b8a6);
color: #000;
}
.cw-step.done .cw-step__num {
border-color: var(--rs-accent, #14b8a6);
background: var(--rs-accent, #14b8a6);
color: #000;
}
.cw-step.active {
color: var(--rs-text-primary, #e1e1e1);
}
.cw-step.done {
color: var(--rs-text-secondary, #94a3b8);
}
.cw-step__line {
flex: 1;
height: 2px;
min-width: 20px;
background: var(--rs-border, #333);
margin: 0 0.25rem;
}
.cw-step.done + .cw-step__line,
.cw-step.done ~ .cw-step__line {
background: var(--rs-accent, #14b8a6);
}
/* ── Step panels ── */
.cw-panel {
background: var(--rs-bg-surface, #1e1e2e);
border: 1px solid var(--rs-border, #333);
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.cw-panel h2 {
font-size: 1.2rem;
margin: 0 0 0.5rem;
}
.cw-panel p.cw-hint {
color: var(--rs-text-secondary, #94a3b8);
font-size: 0.85rem;
margin: 0 0 1rem;
}
/* ── Brief textarea ── */
.cw-textarea {
width: 100%;
min-height: 180px;
padding: 0.75rem 1rem;
border: 1px solid var(--rs-input-border, #334155);
border-radius: 8px;
background: var(--rs-bg-surface-sunken, #14141e);
color: var(--rs-text-primary, #e1e1e1);
font-family: inherit;
font-size: 0.9rem;
line-height: 1.6;
resize: vertical;
box-sizing: border-box;
}
.cw-textarea:focus {
outline: none;
border-color: var(--rs-accent, #14b8a6);
}
/* ── Action buttons ── */
.cw-actions {
display: flex;
gap: 0.75rem;
margin-top: 1.25rem;
flex-wrap: wrap;
}
.cw-btn {
padding: 0.6rem 1.25rem;
border-radius: 8px;
border: 1px solid var(--rs-border, #444);
background: var(--rs-bg-surface, #252538);
color: var(--rs-text-primary, #e1e1e1);
font-size: 0.85rem;
cursor: pointer;
transition: all 0.15s;
font-family: inherit;
}
.cw-btn:hover {
border-color: var(--rs-accent, #14b8a6);
background: var(--rs-surface-hover, #2a2a40);
}
.cw-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.cw-btn--primary {
background: var(--rs-accent, #14b8a6);
color: #000;
border-color: var(--rs-accent, #14b8a6);
font-weight: 600;
}
.cw-btn--primary:hover {
opacity: 0.9;
}
.cw-btn--danger {
border-color: #dc2626;
color: #f87171;
}
.cw-btn--danger:hover {
background: rgba(220, 38, 38, 0.15);
}
.cw-btn--ghost {
background: transparent;
border-color: transparent;
color: var(--rs-text-secondary, #94a3b8);
}
.cw-btn--ghost:hover {
color: var(--rs-text-primary, #e1e1e1);
}
/* ── Extracted brief summary ── */
.cw-brief-summary {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
margin-bottom: 1rem;
}
.cw-brief-field {
background: var(--rs-bg-surface-sunken, #14141e);
padding: 0.6rem 0.85rem;
border-radius: 8px;
border: 1px solid var(--rs-border, #333);
}
.cw-brief-field__label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--rs-text-muted, #64748b);
margin-bottom: 0.2rem;
}
.cw-brief-field__value {
font-size: 0.88rem;
color: var(--rs-text-primary, #e1e1e1);
}
.cw-brief-field--wide {
grid-column: 1 / -1;
}
/* ── Phase cards ── */
.cw-phases {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1rem;
}
.cw-phase-card {
background: var(--rs-bg-surface-sunken, #14141e);
border: 1px solid var(--rs-border, #333);
border-radius: 10px;
padding: 1rem 1.25rem;
}
.cw-phase-card__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.cw-phase-card__name {
font-weight: 600;
font-size: 0.95rem;
}
.cw-phase-card__days {
font-size: 0.8rem;
color: var(--rs-text-muted, #64748b);
}
.cw-phase-card__cadence {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.cw-cadence-badge {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
background: rgba(20, 184, 166, 0.1);
color: var(--rs-accent, #14b8a6);
border: 1px solid rgba(20, 184, 166, 0.2);
}
/* ── Post review table ── */
.cw-posts-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.cw-posts-table th {
text-align: left;
padding: 0.6rem 0.75rem;
border-bottom: 2px solid var(--rs-border, #333);
color: var(--rs-text-muted, #64748b);
font-weight: 600;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.cw-posts-table td {
padding: 0.6rem 0.75rem;
border-bottom: 1px solid var(--rs-border, #262626);
vertical-align: top;
}
.cw-posts-table tr:hover td {
background: var(--rs-bg-surface-sunken, #14141e);
}
.cw-post-content {
max-width: 400px;
white-space: pre-wrap;
word-break: break-word;
line-height: 1.45;
}
.cw-post-content--truncated {
max-height: 80px;
overflow: hidden;
position: relative;
}
.cw-post-content--truncated::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 24px;
background: linear-gradient(transparent, var(--rs-bg-surface, #1e1e2e));
}
.cw-platform-badge {
display: inline-block;
padding: 0.15rem 0.45rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
text-transform: capitalize;
}
.cw-platform-badge--x { background: #1d9bf01a; color: #1d9bf0; }
.cw-platform-badge--linkedin { background: #0a66c21a; color: #0a66c2; }
.cw-platform-badge--instagram { background: #e1306c1a; color: #e1306c; }
.cw-platform-badge--threads { background: #ffffff1a; color: #ccc; }
.cw-platform-badge--bluesky { background: #0085ff1a; color: #0085ff; }
.cw-platform-badge--youtube { background: #ff00001a; color: #ff4444; }
.cw-platform-badge--newsletter { background: #10b9811a; color: #10b981; }
/* ── Loading / spinner ── */
.cw-loading {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 2rem;
justify-content: center;
color: var(--rs-text-secondary, #94a3b8);
}
.cw-spinner {
width: 20px;
height: 20px;
border: 2px solid var(--rs-border, #333);
border-top-color: var(--rs-accent, #14b8a6);
border-radius: 50%;
animation: cw-spin 0.6s linear infinite;
}
@keyframes cw-spin {
to { transform: rotate(360deg); }
}
/* ── Success panel ── */
.cw-success {
text-align: center;
padding: 2rem 1rem;
}
.cw-success__icon {
font-size: 3rem;
margin-bottom: 0.75rem;
}
.cw-success__links {
display: flex;
gap: 0.75rem;
justify-content: center;
margin-top: 1.5rem;
flex-wrap: wrap;
}
.cw-success__links a {
color: var(--rs-accent, #14b8a6);
text-decoration: none;
padding: 0.5rem 1rem;
border: 1px solid var(--rs-accent, #14b8a6);
border-radius: 8px;
font-size: 0.85rem;
transition: background 0.15s;
}
.cw-success__links a:hover {
background: rgba(20, 184, 166, 0.1);
}
/* ── Responsive ── */
@media (max-width: 640px) {
.cw-brief-summary {
grid-template-columns: 1fr;
}
.cw-steps {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.cw-posts-table {
display: block;
overflow-x: auto;
}
}

View File

@ -0,0 +1,622 @@
/**
* <folk-campaign-wizard> AI-guided step-by-step campaign creation.
*
* Steps: Brief Structure Content Review Activate
* Each step has Approve / Edit / Regenerate controls.
* Commits campaign, threads, newsletters, and workflow DAG on activation.
*/
type WizardStep = 'brief' | 'structure' | 'content' | 'review' | 'activate';
interface ExtractedBrief {
title: string;
audience: string;
startDate: string;
endDate: string;
platforms: string[];
tone: string;
style: string;
keyMessages: string[];
}
interface CampaignStructurePhase {
name: string;
label: string;
days: string;
platforms: string[];
cadence: Record<string, number>;
}
interface CampaignStructure {
phases: CampaignStructurePhase[];
summary: string;
}
interface CampaignPost {
id: string;
platform: string;
postType: string;
stepNumber: number;
content: string;
scheduledAt: string;
status: string;
hashtags: string[];
phase: number;
phaseLabel: string;
threadPosts?: string[];
emailSubject?: string;
emailHtml?: string;
}
interface Campaign {
id: string;
title: string;
description: string;
duration: string;
platforms: string[];
phases: { name: string; label: string; days: string }[];
posts: CampaignPost[];
createdAt: number;
updatedAt: number;
}
interface WizardState {
id: string;
step: WizardStep | 'committed' | 'abandoned';
rawBrief: string;
extractedBrief: ExtractedBrief | null;
structure: CampaignStructure | null;
campaignDraft: Campaign | null;
committedCampaignId: string | null;
}
const STEPS: { key: WizardStep; label: string; num: number }[] = [
{ key: 'brief', label: 'Brief', num: 1 },
{ key: 'structure', label: 'Structure', num: 2 },
{ key: 'content', label: 'Content', num: 3 },
{ key: 'review', label: 'Review', num: 4 },
{ key: 'activate', label: 'Activate', num: 5 },
];
const STEP_ORDER: Record<string, number> = { brief: 0, structure: 1, content: 2, review: 3, activate: 4, committed: 5, abandoned: -1 };
const PLATFORM_ICONS: Record<string, string> = {
x: '\uD83D\uDC26', linkedin: '\uD83D\uDCBC', instagram: '\uD83D\uDCF7',
threads: '\uD83E\uDDF5', bluesky: '\u2601\uFE0F', youtube: '\u25B6\uFE0F',
newsletter: '\uD83D\uDCE7',
};
export class FolkCampaignWizard extends HTMLElement {
private _space = 'demo';
private _wizardId = '';
private _step: WizardStep = 'brief';
private _loading = false;
private _error = '';
private _rawBrief = '';
private _extractedBrief: ExtractedBrief | null = null;
private _structure: CampaignStructure | null = null;
private _campaignDraft: Campaign | null = null;
private _committedCampaignId: string | null = null;
private _commitResult: any = null;
private _expandedPosts: Set<number> = new Set();
private _regenIndex = -1;
private _regenInstructions = '';
static get observedAttributes() { return ['space', 'wizard-id']; }
connectedCallback() {
if (!this.shadowRoot) this.attachShadow({ mode: 'open' });
this._space = this.getAttribute('space') || 'demo';
this._wizardId = this.getAttribute('wizard-id') || '';
if (this._wizardId) {
this.loadWizard();
} else {
this.render();
}
}
attributeChangedCallback(name: string, _old: string, val: string) {
if (name === 'space') this._space = val;
if (name === 'wizard-id' && val !== this._wizardId) {
this._wizardId = val;
if (val) this.loadWizard();
}
}
private get basePath(): string {
return `/${encodeURIComponent(this._space)}/rsocials`;
}
private async apiFetch(path: string, opts: RequestInit = {}): Promise<Response> {
const token = (window as any).__authToken || localStorage.getItem('auth_token') || '';
return fetch(`${this.basePath}${path}`, {
...opts,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(opts.headers || {}),
},
});
}
private async loadWizard(): Promise<void> {
this._loading = true;
this.render();
try {
const res = await this.apiFetch(`/api/campaign/wizard/${this._wizardId}`);
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Failed to load wizard');
const data: WizardState = await res.json();
this._rawBrief = data.rawBrief || '';
this._extractedBrief = data.extractedBrief;
this._structure = data.structure;
this._campaignDraft = data.campaignDraft;
this._committedCampaignId = data.committedCampaignId;
if (data.step === 'committed') {
this._step = 'activate';
} else if (data.step === 'abandoned') {
this._step = 'brief';
} else {
this._step = data.step as WizardStep;
}
} catch (e: any) {
this._error = e.message;
}
this._loading = false;
this.render();
}
private async createWizard(): Promise<string | null> {
try {
const res = await this.apiFetch('/api/campaign/wizard', {
method: 'POST',
body: JSON.stringify({ rawBrief: this._rawBrief }),
});
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Failed to create wizard');
const data = await res.json();
this._wizardId = data.wizardId;
// Update URL without reload
const url = `${this.basePath}/campaign-wizard/${data.wizardId}`;
history.replaceState(null, '', url);
return data.wizardId;
} catch (e: any) {
this._error = e.message;
this.render();
return null;
}
}
// ── Step actions ──
private async analyzebrief(): Promise<void> {
if (this._rawBrief.trim().length < 10) {
this._error = 'Brief must be at least 10 characters';
this.render();
return;
}
this._loading = true;
this._error = '';
this.render();
if (!this._wizardId) {
const id = await this.createWizard();
if (!id) { this._loading = false; this.render(); return; }
}
try {
const res = await this.apiFetch(`/api/campaign/wizard/${this._wizardId}/structure`, {
method: 'POST',
body: JSON.stringify({ rawBrief: this._rawBrief }),
});
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Analysis failed');
const data: WizardState = await res.json();
this._extractedBrief = data.extractedBrief;
this._structure = data.structure;
this._step = 'structure';
} catch (e: any) {
this._error = e.message;
}
this._loading = false;
this.render();
}
private async generateContent(): Promise<void> {
this._loading = true;
this._error = '';
this.render();
try {
const res = await this.apiFetch(`/api/campaign/wizard/${this._wizardId}/content`, {
method: 'POST',
body: JSON.stringify({}),
});
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Generation failed');
const data: WizardState = await res.json();
this._campaignDraft = data.campaignDraft;
this._step = 'content';
} catch (e: any) {
this._error = e.message;
}
this._loading = false;
this.render();
}
private async regenPost(index: number): Promise<void> {
this._regenIndex = index;
this._loading = true;
this._error = '';
this.render();
try {
const res = await this.apiFetch(`/api/campaign/wizard/${this._wizardId}/regen-post`, {
method: 'POST',
body: JSON.stringify({ postIndex: index, instructions: this._regenInstructions }),
});
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Regeneration failed');
const newPost: CampaignPost = await res.json();
if (this._campaignDraft) {
this._campaignDraft.posts[index] = { ...this._campaignDraft.posts[index], ...newPost };
}
} catch (e: any) {
this._error = e.message;
}
this._regenIndex = -1;
this._regenInstructions = '';
this._loading = false;
this.render();
}
private async commitCampaign(): Promise<void> {
this._loading = true;
this._error = '';
this.render();
try {
const res = await this.apiFetch(`/api/campaign/wizard/${this._wizardId}/commit`, {
method: 'POST',
});
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Commit failed');
this._commitResult = await res.json();
this._committedCampaignId = this._commitResult.campaignId;
this._step = 'activate';
} catch (e: any) {
this._error = e.message;
}
this._loading = false;
this.render();
}
private async abandonWizard(): Promise<void> {
if (!this._wizardId) return;
await this.apiFetch(`/api/campaign/wizard/${this._wizardId}`, { method: 'DELETE' });
window.location.href = `${this.basePath}/campaign-wizard`;
}
// ── Render ──
private render(): void {
if (!this.shadowRoot) return;
this.shadowRoot.innerHTML = `
<link rel="stylesheet" href="/modules/rsocials/campaign-wizard.css">
<div class="cw-container">
${this.renderHeader()}
${this.renderStepIndicator()}
${this._loading && this._step !== 'review' ? this.renderLoading() : this.renderCurrentStep()}
${this._error ? `<div class="cw-panel" style="border-color:#dc2626;color:#f87171">${this.escHtml(this._error)}</div>` : ''}
</div>
`;
this.bindEvents();
}
private renderHeader(): string {
return `<div class="cw-header">
<h1>Campaign Wizard</h1>
<p>AI-guided campaign creation with step-by-step approval</p>
</div>`;
}
private renderStepIndicator(): string {
const current = STEP_ORDER[this._step] ?? 0;
const committed = this._committedCampaignId != null;
const items = STEPS.map((s, i) => {
const isDone = committed || current > i;
const isActive = !committed && current === i;
const cls = isDone ? 'done' : isActive ? 'active' : '';
const icon = isDone ? '\u2713' : String(s.num);
return `
<li class="cw-step ${cls}">
<span class="cw-step__num">${icon}</span>
<span>${s.label}</span>
</li>
${i < STEPS.length - 1 ? `<li class="cw-step__line" style="background:${isDone ? 'var(--rs-accent,#14b8a6)' : 'var(--rs-border,#333)'}"></li>` : ''}
`;
}).join('');
return `<ul class="cw-steps">${items}</ul>`;
}
private renderLoading(): string {
const labels: Record<string, string> = {
brief: 'Analyzing your brief...',
structure: 'Generating campaign structure...',
content: 'Generating posts for each platform...',
review: 'Regenerating post...',
activate: 'Committing campaign...',
};
return `<div class="cw-loading"><div class="cw-spinner"></div><span>${labels[this._step] || 'Working...'}</span></div>`;
}
private renderCurrentStep(): string {
if (this._committedCampaignId) return this.renderActivateStep();
switch (this._step) {
case 'brief': return this.renderBriefStep();
case 'structure': return this.renderStructureStep();
case 'content': return this.renderContentStep();
case 'review': return this.renderReviewStep();
case 'activate': return this.renderActivateStep();
default: return this.renderBriefStep();
}
}
private renderBriefStep(): string {
return `<div class="cw-panel">
<h2>Step 1: Paste Your Campaign Brief</h2>
<p class="cw-hint">Paste raw text describing your event, product launch, or campaign goals. The AI will extract key details and propose a structure.</p>
<textarea class="cw-textarea" id="brief-input" placeholder="Example: We're launching MycoFi at ETHDenver on March 25, 2026. Target audience is web3 developers and DeFi enthusiasts. We want to build hype for 2 weeks before, have a strong presence during the 3-day event, and follow up for a week after...">${this.escHtml(this._rawBrief)}</textarea>
<div class="cw-actions">
<button class="cw-btn cw-btn--primary" id="analyze-btn" ${this._rawBrief.trim().length < 10 ? 'disabled' : ''}>Analyze Brief</button>
<button class="cw-btn cw-btn--ghost" id="mi-btn">Refine with MI</button>
</div>
</div>`;
}
private renderStructureStep(): string {
const brief = this._extractedBrief;
const structure = this._structure;
if (!brief || !structure) return '<div class="cw-panel">No structure data. Go back to Step 1.</div>';
const briefFields = `
<div class="cw-brief-summary">
<div class="cw-brief-field"><div class="cw-brief-field__label">Title</div><div class="cw-brief-field__value">${this.escHtml(brief.title)}</div></div>
<div class="cw-brief-field"><div class="cw-brief-field__label">Audience</div><div class="cw-brief-field__value">${this.escHtml(brief.audience)}</div></div>
<div class="cw-brief-field"><div class="cw-brief-field__label">Dates</div><div class="cw-brief-field__value">${this.escHtml(brief.startDate)} to ${this.escHtml(brief.endDate)}</div></div>
<div class="cw-brief-field"><div class="cw-brief-field__label">Tone / Style</div><div class="cw-brief-field__value">${this.escHtml(brief.tone)} / ${this.escHtml(brief.style)}</div></div>
<div class="cw-brief-field"><div class="cw-brief-field__label">Platforms</div><div class="cw-brief-field__value">${brief.platforms.map(p => `${PLATFORM_ICONS[p] || ''} ${p}`).join(', ')}</div></div>
<div class="cw-brief-field cw-brief-field--wide"><div class="cw-brief-field__label">Key Messages</div><div class="cw-brief-field__value">${brief.keyMessages.map(m => `<div>- ${this.escHtml(m)}</div>`).join('')}</div></div>
</div>
`;
const phases = structure.phases.map(p => `
<div class="cw-phase-card">
<div class="cw-phase-card__header">
<span class="cw-phase-card__name">${this.escHtml(p.label)}</span>
<span class="cw-phase-card__days">${this.escHtml(p.days)}</span>
</div>
<div class="cw-phase-card__cadence">
${Object.entries(p.cadence || {}).map(([plat, count]) =>
`<span class="cw-cadence-badge">${PLATFORM_ICONS[plat] || ''} ${plat}: ${count} post${count !== 1 ? 's' : ''}</span>`
).join('')}
</div>
</div>
`).join('');
return `
<div class="cw-panel">
<h2>Step 2: Review Campaign Structure</h2>
<p class="cw-hint">${this.escHtml(structure.summary)}</p>
${briefFields}
</div>
<div class="cw-panel">
<h2>Proposed Phases</h2>
<div class="cw-phases">${phases}</div>
<div class="cw-actions">
<button class="cw-btn cw-btn--primary" id="approve-structure-btn">Approve & Generate Content</button>
<button class="cw-btn" id="regen-structure-btn">Regenerate Structure</button>
<button class="cw-btn cw-btn--ghost" id="back-to-brief-btn">Back to Brief</button>
</div>
</div>
`;
}
private renderContentStep(): string {
if (!this._campaignDraft) return '<div class="cw-panel">No content generated yet.</div>';
const campaign = this._campaignDraft;
const postsByPhase = new Map<number, CampaignPost[]>();
for (const post of campaign.posts) {
const arr = postsByPhase.get(post.phase) || [];
arr.push(post);
postsByPhase.set(post.phase, arr);
}
const phaseSections = campaign.phases.map((phase, pi) => {
const posts = postsByPhase.get(pi + 1) || [];
const postCards = posts.map(p => `
<div class="cw-phase-card" style="margin-bottom:0.5rem">
<div class="cw-phase-card__header">
<span><span class="cw-platform-badge cw-platform-badge--${p.platform}">${PLATFORM_ICONS[p.platform] || ''} ${p.platform}</span> ${this.escHtml(p.postType)}</span>
<span class="cw-phase-card__days">${p.scheduledAt?.split('T')[0] || ''}</span>
</div>
<div class="cw-post-content cw-post-content--truncated">${this.escHtml(p.content)}</div>
${p.threadPosts?.length ? `<div style="font-size:0.75rem;color:var(--rs-text-muted,#64748b);margin-top:0.25rem">${p.threadPosts.length} tweets in thread</div>` : ''}
${p.hashtags?.length ? `<div style="font-size:0.75rem;color:var(--rs-accent,#14b8a6);margin-top:0.25rem">#${p.hashtags.join(' #')}</div>` : ''}
</div>
`).join('');
return `
<div class="cw-panel">
<h2>${this.escHtml(phase.label)} <span style="font-weight:400;font-size:0.85rem;color:var(--rs-text-muted,#64748b)">(${this.escHtml(phase.days)})</span></h2>
<div class="cw-phases">${postCards}</div>
</div>
`;
}).join('');
return `
<div class="cw-panel">
<h2>Step 3: Generated Content</h2>
<p class="cw-hint">${campaign.posts.length} posts across ${campaign.platforms.length} platforms in ${campaign.phases.length} phases</p>
</div>
${phaseSections}
<div class="cw-actions" style="margin-bottom:1.5rem">
<button class="cw-btn cw-btn--primary" id="approve-content-btn">Proceed to Review</button>
<button class="cw-btn" id="regen-content-btn">Regenerate All Content</button>
<button class="cw-btn cw-btn--ghost" id="back-to-structure-btn">Back to Structure</button>
</div>
`;
}
private renderReviewStep(): string {
if (!this._campaignDraft) return '<div class="cw-panel">No campaign to review.</div>';
const campaign = this._campaignDraft;
const rows = campaign.posts.map((p, i) => {
const isExpanded = this._expandedPosts.has(i);
const isRegening = this._regenIndex === i && this._loading;
const contentCls = isExpanded ? 'cw-post-content' : 'cw-post-content cw-post-content--truncated';
return `<tr>
<td>${p.stepNumber}</td>
<td><span class="cw-platform-badge cw-platform-badge--${p.platform}">${PLATFORM_ICONS[p.platform] || ''} ${p.platform}</span></td>
<td>${this.escHtml(p.phaseLabel)}</td>
<td>
<div class="${contentCls}" data-expand="${i}" style="cursor:pointer">${this.escHtml(p.content)}</div>
${p.threadPosts?.length ? `<div style="font-size:0.7rem;color:var(--rs-text-muted)">${p.threadPosts.length} tweets</div>` : ''}
${p.emailSubject ? `<div style="font-size:0.7rem;color:var(--rs-text-muted)">Subject: ${this.escHtml(p.emailSubject)}</div>` : ''}
</td>
<td>${p.scheduledAt?.split('T')[0] || '-'}</td>
<td>
${isRegening
? '<div class="cw-spinner" style="width:16px;height:16px"></div>'
: `<button class="cw-btn" style="padding:0.3rem 0.6rem;font-size:0.75rem" data-regen="${i}">Regen</button>`
}
</td>
</tr>`;
}).join('');
return `
<div class="cw-panel">
<h2>Step 4: Review All Posts</h2>
<p class="cw-hint">Click any post content to expand. Use Regen to regenerate individual posts.</p>
<div style="overflow-x:auto">
<table class="cw-posts-table">
<thead><tr>
<th>#</th><th>Platform</th><th>Phase</th><th>Content</th><th>Scheduled</th><th></th>
</tr></thead>
<tbody>${rows}</tbody>
</table>
</div>
<div class="cw-actions">
<button class="cw-btn cw-btn--primary" id="commit-btn">Activate Campaign</button>
<button class="cw-btn cw-btn--ghost" id="back-to-content-btn">Back to Content</button>
<button class="cw-btn cw-btn--danger" id="abandon-btn">Abandon Wizard</button>
</div>
</div>
`;
}
private renderActivateStep(): string {
if (this._committedCampaignId) {
const result = this._commitResult || {};
return `<div class="cw-panel">
<div class="cw-success">
<div class="cw-success__icon">\u2705</div>
<h2>Campaign Activated!</h2>
<p style="color:var(--rs-text-secondary,#94a3b8)">Your campaign has been committed successfully.</p>
${result.threadIds?.length ? `<p style="font-size:0.85rem;color:var(--rs-text-muted,#64748b)">${result.threadIds.length} thread(s) created</p>` : ''}
${result.newsletters?.length ? `<p style="font-size:0.85rem;color:var(--rs-text-muted,#64748b)">${result.newsletters.length} newsletter draft(s)</p>` : ''}
<div class="cw-success__links">
<a href="${this.basePath}/campaign">View Campaign</a>
<a href="${this.basePath}/threads">View Threads</a>
<a href="${this.basePath}/campaigns?workflow=${result.workflowId || ''}">View Workflow</a>
<a href="${this.basePath}/campaign-wizard">New Wizard</a>
</div>
</div>
</div>`;
}
return `<div class="cw-panel">
<h2>Step 5: Activate</h2>
<p class="cw-hint">Click Activate to commit the campaign, create threads, draft newsletters, and build the workflow.</p>
<div class="cw-actions">
<button class="cw-btn cw-btn--primary" id="commit-btn">Activate Campaign</button>
<button class="cw-btn cw-btn--ghost" id="back-to-review-btn">Back to Review</button>
</div>
</div>`;
}
// ── Event binding ──
private bindEvents(): void {
const sr = this.shadowRoot!;
// Brief step
const briefInput = sr.querySelector('#brief-input') as HTMLTextAreaElement | null;
if (briefInput) {
briefInput.addEventListener('input', () => {
this._rawBrief = briefInput.value;
const btn = sr.querySelector('#analyze-btn') as HTMLButtonElement | null;
if (btn) btn.disabled = this._rawBrief.trim().length < 10;
});
}
sr.querySelector('#analyze-btn')?.addEventListener('click', () => this.analyzebrief());
sr.querySelector('#mi-btn')?.addEventListener('click', () => {
this.dispatchEvent(new CustomEvent('mi-prompt', {
bubbles: true, composed: true,
detail: { prompt: `Help me refine this campaign brief:\n\n${this._rawBrief}` },
}));
});
// Structure step
sr.querySelector('#approve-structure-btn')?.addEventListener('click', () => this.generateContent());
sr.querySelector('#regen-structure-btn')?.addEventListener('click', () => this.analyzebrief());
sr.querySelector('#back-to-brief-btn')?.addEventListener('click', () => { this._step = 'brief'; this.render(); });
// Content step
sr.querySelector('#approve-content-btn')?.addEventListener('click', () => { this._step = 'review'; this.render(); });
sr.querySelector('#regen-content-btn')?.addEventListener('click', () => this.generateContent());
sr.querySelector('#back-to-structure-btn')?.addEventListener('click', () => { this._step = 'structure'; this.render(); });
// Review step — expand and regen
sr.querySelectorAll('[data-expand]').forEach(el => {
el.addEventListener('click', () => {
const idx = parseInt(el.getAttribute('data-expand')!);
if (this._expandedPosts.has(idx)) this._expandedPosts.delete(idx);
else this._expandedPosts.add(idx);
this.render();
});
});
sr.querySelectorAll('[data-regen]').forEach(el => {
el.addEventListener('click', () => {
const idx = parseInt(el.getAttribute('data-regen')!);
this.regenPost(idx);
});
});
// Commit / back / abandon
sr.querySelector('#commit-btn')?.addEventListener('click', () => this.commitCampaign());
sr.querySelector('#back-to-content-btn')?.addEventListener('click', () => { this._step = 'content'; this.render(); });
sr.querySelector('#back-to-review-btn')?.addEventListener('click', () => { this._step = 'review'; this.render(); });
sr.querySelector('#abandon-btn')?.addEventListener('click', () => {
if (confirm('Are you sure you want to abandon this wizard? All progress will be lost.')) {
this.abandonWizard();
}
});
}
private escHtml(s: string): string {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
}
customElements.define('folk-campaign-wizard', FolkCampaignWizard);

View File

@ -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, CampaignWorkflow, CampaignWorkflowNode, CampaignWorkflowEdge } from './schemas';
import type { SocialsDoc, ThreadData, Campaign, CampaignFlow, CampaignPlannerNode, CampaignEdge, CampaignWorkflow, CampaignWorkflowNode, CampaignWorkflowEdge, CampaignWizard } from './schemas';
export class SocialsLocalFirstClient {
#space: string;
@ -236,6 +236,38 @@ export class SocialsLocalFirstClient {
});
}
// ── Campaign wizard reads ──
listCampaignWizards(): CampaignWizard[] {
const doc = this.getDoc();
if (!doc?.campaignWizards) return [];
return Object.values(doc.campaignWizards).sort((a, b) => b.updatedAt - a.updatedAt);
}
getCampaignWizard(id: string): CampaignWizard | undefined {
const doc = this.getDoc();
return doc?.campaignWizards?.[id];
}
// ── Campaign wizard writes ──
saveCampaignWizard(wizard: CampaignWizard): void {
const docId = socialsDocId(this.#space) as DocumentId;
this.#sync.change<SocialsDoc>(docId, `Save campaign wizard ${wizard.id}`, (d) => {
if (!d.campaignWizards) d.campaignWizards = {} as any;
wizard.updatedAt = Date.now();
if (!wizard.createdAt) wizard.createdAt = Date.now();
d.campaignWizards[wizard.id] = wizard;
});
}
deleteCampaignWizard(id: string): void {
const docId = socialsDocId(this.#space) as DocumentId;
this.#sync.change<SocialsDoc>(docId, `Delete campaign wizard ${id}`, (d) => {
if (d.campaignWizards?.[id]) delete d.campaignWizards[id];
});
}
// ── Events ──
onChange(cb: (doc: SocialsDoc) => void): () => void {

View File

@ -19,7 +19,7 @@ import type { RSpaceModule } from "../../shared/module";
import type { SyncServer } from "../../server/local-first/sync-server";
import { renderLanding } from "./landing";
import { MYCOFI_CAMPAIGN, buildDemoCampaignFlow, buildDemoCampaignWorkflow } from "./campaign-data";
import { socialsSchema, socialsDocId, type SocialsDoc, type ThreadData, type CampaignWorkflow, type CampaignWorkflowNode, type CampaignWorkflowEdge, type PendingApproval } from "./schemas";
import { socialsSchema, socialsDocId, type SocialsDoc, type ThreadData, type Campaign, type CampaignWorkflow, type CampaignWorkflowNode, type CampaignWorkflowEdge, type PendingApproval, type CampaignWizard } from "./schemas";
import {
generateImageFromPrompt,
downloadAndSaveImage,
@ -57,6 +57,7 @@ function ensureDoc(space: string): SocialsDoc {
d.activeFlowId = '';
d.campaignWorkflows = {};
d.pendingApprovals = {};
d.campaignWizards = {};
});
_syncServer!.setDoc(docId, doc);
}
@ -979,8 +980,57 @@ routes.post("/api/campaign-workflows/:id/run", async (c) => {
break;
}
case 'send-newsletter': {
// Newsletter sending via Listmonk — log only for now
results.push({ nodeId: node.id, status: 'success', message: `[listmonk] Newsletter node logged (subject: ${cfg.subject || 'N/A'})`, durationMs: Date.now() - start });
const listmonkConfig = await getListmonkConfig(dataSpace);
if (!listmonkConfig) {
results.push({ nodeId: node.id, status: 'success', message: '[listmonk] Not configured — skipped', durationMs: Date.now() - start });
break;
}
// Create Listmonk campaign as draft
const nlSubject = (cfg.subject as string) || workflow.name || 'Newsletter';
const nlBody = (cfg.body as string) || '';
const nlListIds = Array.isArray(cfg.listIds) ? cfg.listIds as number[] : [1];
const createRes = await listmonkFetch(listmonkConfig, '/api/campaigns', {
method: 'POST',
body: JSON.stringify({
name: nlSubject,
subject: nlSubject,
body: nlBody || '<p>Newsletter content</p>',
content_type: 'richtext',
type: 'regular',
lists: nlListIds,
status: 'draft',
}),
});
if (!createRes.ok) {
const err = await createRes.json().catch(() => ({}));
throw new Error(`Listmonk campaign create failed: ${(err as any).message || createRes.status}`);
}
const campaignData = await createRes.json() as { data?: { id?: number } };
const campaignId = campaignData.data?.id;
if (!campaignId) throw new Error('Listmonk returned no campaign ID');
// Try to gate via rInbox approval
const { createNewsletterApproval } = await import('../../modules/rinbox/mod');
const approvalResult = createNewsletterApproval({
space: dataSpace,
authorId: claims?.did || 'workflow',
subject: nlSubject,
bodyText: nlBody,
bodyHtml: nlBody,
listmonkCampaignId: campaignId,
});
if (approvalResult) {
// Pause workflow — approval required
results.push({ nodeId: node.id, status: 'paused', message: `Newsletter campaign #${campaignId} awaiting approval (${approvalResult.id})`, durationMs: Date.now() - start });
paused = true;
} else {
// No team inbox → start immediately
const { startListmonkCampaign } = await import('./lib/listmonk-proxy');
const sendResult = await startListmonkCampaign(listmonkConfig, campaignId);
if (!sendResult.ok) throw new Error(sendResult.error || 'Failed to start campaign');
results.push({ nodeId: node.id, status: 'success', message: `Newsletter campaign #${campaignId} started (no approval gate)`, durationMs: Date.now() - start });
}
break;
}
case 'post-webhook': {
@ -1096,8 +1146,571 @@ function topologicalSortCampaign(nodes: CampaignWorkflowNode[], edges: CampaignW
return sorted;
}
// ── Campaign Wizard API ──
routes.post("/api/campaign/wizard", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = c.get("effectiveSpace") || space;
const body = await c.req.json().catch(() => ({}));
const docId = socialsDocId(dataSpace);
ensureDoc(dataSpace);
const wizardId = `wiz-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`;
const now = Date.now();
const wizard: CampaignWizard = {
id: wizardId,
step: 'brief',
rawBrief: body.rawBrief || '',
extractedBrief: null,
structure: null,
campaignDraft: null,
committedCampaignId: null,
createdAt: now,
updatedAt: now,
createdBy: null,
};
_syncServer!.changeDoc<SocialsDoc>(docId, `create campaign wizard ${wizardId}`, (d) => {
if (!d.campaignWizards) d.campaignWizards = {} as any;
(d.campaignWizards as any)[wizardId] = wizard;
});
return c.json({ wizardId, url: `/${space}/rsocials/campaign-wizard/${wizardId}` }, 201);
});
routes.get("/api/campaign/wizard/:id", (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = c.get("effectiveSpace") || space;
const id = c.req.param("id");
const doc = ensureDoc(dataSpace);
const wizard = doc.campaignWizards?.[id];
if (!wizard) return c.json({ error: "Wizard not found" }, 404);
return c.json(wizard);
});
routes.post("/api/campaign/wizard/:id/structure", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = c.get("effectiveSpace") || space;
const id = c.req.param("id");
const GEMINI_API_KEY = process.env.GEMINI_API_KEY || "";
if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503);
const docId = socialsDocId(dataSpace);
const doc = ensureDoc(dataSpace);
const wizard = doc.campaignWizards?.[id];
if (!wizard) return c.json({ error: "Wizard not found" }, 404);
const body = await c.req.json().catch(() => ({}));
const rawBrief = body.rawBrief || wizard.rawBrief;
if (!rawBrief || rawBrief.trim().length < 10) {
return c.json({ error: "Brief is required (min 10 characters)" }, 400);
}
const { GoogleGenerativeAI } = await import("@google/generative-ai");
const genAI = new GoogleGenerativeAI(GEMINI_API_KEY);
const model = genAI.getGenerativeModel({
model: "gemini-2.5-flash",
generationConfig: { responseMimeType: "application/json" } as any,
});
const prompt = `You are a social media campaign strategist. Analyze this campaign brief and extract structured data.
Brief:
"""
${rawBrief.trim()}
"""
Return JSON with this exact shape:
{
"extractedBrief": {
"title": "Campaign title derived from the brief",
"audience": "Target audience description",
"startDate": "YYYY-MM-DD (infer from brief or use 7 days from now)",
"endDate": "YYYY-MM-DD (infer from brief or use 30 days from start)",
"platforms": ["x", "linkedin", "instagram", "threads", "bluesky", "newsletter"],
"tone": "professional | casual | urgent | inspirational",
"style": "event-promo | product-launch | awareness | community | educational",
"keyMessages": ["Key message 1", "Key message 2", "Key message 3"]
},
"structure": {
"phases": [
{
"name": "phase-slug",
"label": "Phase Label",
"days": "Day 1-3",
"platforms": ["x", "linkedin"],
"cadence": { "x": 3, "linkedin": 2 }
}
],
"summary": "One paragraph summary of the campaign strategy"
}
}
Rules:
- Generate 3-5 phases (pre-launch, launch, amplification, follow-up, etc.)
- Cadence = number of posts per platform for that phase
- Platforms should be realistic for the brief content
- Use today's date (${new Date().toISOString().split('T')[0]}) as reference for dates`;
try {
const result = await model.generateContent(prompt);
const text = result.response.text();
const parsed = JSON.parse(text);
_syncServer!.changeDoc<SocialsDoc>(docId, `wizard ${id} → structure`, (d) => {
const w = d.campaignWizards?.[id];
if (!w) return;
w.rawBrief = rawBrief;
w.extractedBrief = parsed.extractedBrief || null;
w.structure = parsed.structure || null;
w.step = 'structure';
w.updatedAt = Date.now();
});
const updated = _syncServer!.getDoc<SocialsDoc>(docId)!;
return c.json(updated.campaignWizards[id]);
} catch (e: any) {
console.error("[rSocials] Wizard structure error:", e.message);
return c.json({ error: "Failed to analyze brief: " + (e.message || "unknown") }, 502);
}
});
routes.post("/api/campaign/wizard/:id/content", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = c.get("effectiveSpace") || space;
const id = c.req.param("id");
const GEMINI_API_KEY = process.env.GEMINI_API_KEY || "";
if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503);
const docId = socialsDocId(dataSpace);
const doc = ensureDoc(dataSpace);
const wizard = doc.campaignWizards?.[id];
if (!wizard) return c.json({ error: "Wizard not found" }, 404);
if (!wizard.extractedBrief || !wizard.structure) {
return c.json({ error: "Must complete structure step first" }, 400);
}
const brief = wizard.extractedBrief;
const structure = wizard.structure;
const selectedPlatforms = brief.platforms.length > 0 ? brief.platforms : ["x", "linkedin", "instagram", "newsletter"];
const { GoogleGenerativeAI } = await import("@google/generative-ai");
const genAI = new GoogleGenerativeAI(GEMINI_API_KEY);
const model = genAI.getGenerativeModel({
model: "gemini-2.5-pro",
generationConfig: { responseMimeType: "application/json" } as any,
});
const prompt = `You are an expert social media campaign strategist. Generate a complete campaign with posts for every phase and platform.
Campaign Brief:
- Title: ${brief.title}
- Audience: ${brief.audience}
- Dates: ${brief.startDate} to ${brief.endDate}
- Tone: ${brief.tone}
- Style: ${brief.style}
- Key Messages: ${brief.keyMessages.join('; ')}
- Platforms: ${selectedPlatforms.join(', ')}
Approved Structure:
${JSON.stringify(structure.phases, null, 2)}
Platform specifications:
- x: Max 280 chars per post. Support threads (threadPosts array). Use 2-4 hashtags, emojis encouraged.
- linkedin: Max 1300 chars. Professional tone. 3-5 hashtags. No emojis in professional mode.
- instagram: Carousel descriptions. 20-30 hashtags. Heavy emoji usage.
- youtube: Video title + description. SEO-focused with keywords.
- threads: Max 500 chars. Casual tone. Support threads (threadPosts array).
- bluesky: Max 300 chars. Conversational. Minimal hashtags (1-2).
- newsletter: HTML email body (emailHtml) with subject line (emailSubject). Include sections, CTA button.
Return JSON:
{
"title": "${brief.title}",
"description": "1-2 sentence campaign description",
"duration": "Human-readable date range",
"platforms": ${JSON.stringify(selectedPlatforms)},
"phases": ${JSON.stringify(structure.phases.map(p => ({ name: p.name, label: p.label, days: p.days })))},
"posts": [
{
"platform": "x",
"postType": "thread",
"stepNumber": 1,
"content": "Main post content",
"scheduledAt": "2026-03-20T09:00:00",
"status": "draft",
"hashtags": ["Tag1", "Tag2"],
"phase": 1,
"phaseLabel": "Phase Label",
"threadPosts": ["Tweet 1", "Tweet 2"],
"emailSubject": null,
"emailHtml": null
}
]
}
Rules:
- Generate posts matching the cadence in each phase (e.g. if cadence says x:3, generate 3 X posts for that phase)
- stepNumber increments globally across all posts
- For X/Threads "thread" postType, include threadPosts array
- For newsletter, include emailSubject + emailHtml with inline CSS
- scheduledAt dates should spread across the phase day ranges, during working hours (9am-5pm)
- Content must reference specific details from the brief key messages
- Respect each platform's character limits`;
try {
const result = await model.generateContent(prompt);
const text = result.response.text();
const generated = JSON.parse(text);
const now = Date.now();
const campaignId = `wiz-${now}`;
const campaign: Campaign = {
id: campaignId,
title: generated.title || brief.title,
description: generated.description || "",
duration: generated.duration || `${brief.startDate} to ${brief.endDate}`,
platforms: generated.platforms || selectedPlatforms,
phases: generated.phases || structure.phases.map(p => ({ name: p.name, label: p.label, days: p.days })),
posts: (generated.posts || []).map((p: any, i: number) => ({
id: `${campaignId}-post-${i}`,
platform: p.platform || "x",
postType: p.postType || "text",
stepNumber: p.stepNumber || i + 1,
content: p.content || "",
scheduledAt: p.scheduledAt || new Date().toISOString(),
status: p.status || "draft",
hashtags: p.hashtags || [],
phase: p.phase || 1,
phaseLabel: p.phaseLabel || "",
...(p.threadPosts ? { threadPosts: p.threadPosts } : {}),
...(p.emailSubject ? { emailSubject: p.emailSubject } : {}),
...(p.emailHtml ? { emailHtml: p.emailHtml } : {}),
})),
createdAt: now,
updatedAt: now,
};
_syncServer!.changeDoc<SocialsDoc>(docId, `wizard ${id} → content`, (d) => {
const w = d.campaignWizards?.[id];
if (!w) return;
w.campaignDraft = campaign;
w.step = 'content';
w.updatedAt = Date.now();
});
const updated = _syncServer!.getDoc<SocialsDoc>(docId)!;
return c.json(updated.campaignWizards[id]);
} catch (e: any) {
console.error("[rSocials] Wizard content error:", e.message);
return c.json({ error: "Failed to generate content: " + (e.message || "unknown") }, 502);
}
});
routes.post("/api/campaign/wizard/:id/regen-post", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = c.get("effectiveSpace") || space;
const id = c.req.param("id");
const GEMINI_API_KEY = process.env.GEMINI_API_KEY || "";
if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503);
const docId = socialsDocId(dataSpace);
const doc = ensureDoc(dataSpace);
const wizard = doc.campaignWizards?.[id];
if (!wizard) return c.json({ error: "Wizard not found" }, 404);
if (!wizard.campaignDraft) return c.json({ error: "No campaign draft to regenerate from" }, 400);
const body = await c.req.json();
const { postIndex, instructions } = body;
if (postIndex === undefined || postIndex < 0 || postIndex >= wizard.campaignDraft.posts.length) {
return c.json({ error: "Invalid postIndex" }, 400);
}
const oldPost = wizard.campaignDraft.posts[postIndex];
const brief = wizard.extractedBrief;
const { GoogleGenerativeAI } = await import("@google/generative-ai");
const genAI = new GoogleGenerativeAI(GEMINI_API_KEY);
const model = genAI.getGenerativeModel({
model: "gemini-2.5-flash",
generationConfig: { responseMimeType: "application/json" } as any,
});
const prompt = `Regenerate this social media post. Keep the same platform, phase, and scheduling but improve the content.
Current post:
${JSON.stringify(oldPost, null, 2)}
Campaign context:
- Title: ${brief?.title || wizard.campaignDraft.title}
- Key messages: ${brief?.keyMessages?.join('; ') || 'N/A'}
- Tone: ${brief?.tone || 'professional'}
${instructions ? `User instructions: ${instructions}` : ''}
Platform limits: x=280 chars, linkedin=1300, instagram=carousel, threads=500, bluesky=300, newsletter=HTML email.
Return the regenerated post as JSON with the same fields (platform, postType, stepNumber, content, scheduledAt, status, hashtags, phase, phaseLabel, threadPosts if applicable, emailSubject/emailHtml if newsletter).`;
try {
const result = await model.generateContent(prompt);
const text = result.response.text();
const newPost = JSON.parse(text);
_syncServer!.changeDoc<SocialsDoc>(docId, `wizard ${id} regen post ${postIndex}`, (d) => {
const w = d.campaignWizards?.[id];
if (!w?.campaignDraft?.posts?.[postIndex]) return;
const p = w.campaignDraft.posts[postIndex];
p.content = newPost.content || p.content;
p.hashtags = newPost.hashtags || p.hashtags;
if (newPost.threadPosts) p.threadPosts = newPost.threadPosts;
if (newPost.emailSubject) p.emailSubject = newPost.emailSubject;
if (newPost.emailHtml) p.emailHtml = newPost.emailHtml;
w.updatedAt = Date.now();
});
const updated = _syncServer!.getDoc<SocialsDoc>(docId)!;
return c.json(updated.campaignWizards[id].campaignDraft!.posts[postIndex]);
} catch (e: any) {
console.error("[rSocials] Wizard regen error:", e.message);
return c.json({ error: "Failed to regenerate post: " + (e.message || "unknown") }, 502);
}
});
routes.post("/api/campaign/wizard/:id/commit", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = c.get("effectiveSpace") || space;
const id = c.req.param("id");
const docId = socialsDocId(dataSpace);
const doc = ensureDoc(dataSpace);
const wizard = doc.campaignWizards?.[id];
if (!wizard) return c.json({ error: "Wizard not found" }, 404);
if (!wizard.campaignDraft) return c.json({ error: "No campaign draft to commit" }, 400);
const campaign = { ...wizard.campaignDraft } as Campaign;
const now = Date.now();
campaign.updatedAt = now;
// 1. Save campaign to SocialsDoc.campaigns
_syncServer!.changeDoc<SocialsDoc>(docId, `wizard ${id} commit campaign`, (d) => {
if (!d.campaigns) d.campaigns = {} as any;
(d.campaigns as any)[campaign.id] = campaign;
});
// 2. Create ThreadData entries for posts with threadPosts
const threadIds: string[] = [];
for (const post of campaign.posts) {
if (post.threadPosts && post.threadPosts.length > 0) {
const threadId = `thread-${now}-${Math.random().toString(36).substring(2, 6)}`;
const threadData: ThreadData = {
id: threadId,
name: post.phaseLabel || 'Campaign Thread',
handle: '@campaign',
title: `${campaign.title}${post.phaseLabel} (${post.platform})`,
tweets: post.threadPosts,
imageUrl: null,
tweetImages: null,
createdAt: now,
updatedAt: now,
};
_syncServer!.changeDoc<SocialsDoc>(docId, `wizard ${id} create thread ${threadId}`, (d) => {
if (!d.threads) d.threads = {} as any;
(d.threads as any)[threadId] = threadData;
});
threadIds.push(threadId);
}
}
// 3. Draft newsletters in Listmonk (skip gracefully if not configured)
const newsletterResults: { subject: string; listmonkId?: number; error?: string }[] = [];
const listmonkConfig = await getListmonkConfig(dataSpace);
for (const post of campaign.posts) {
if (post.platform === 'newsletter' && post.emailSubject && post.emailHtml) {
if (!listmonkConfig) {
newsletterResults.push({ subject: post.emailSubject, error: 'Listmonk not configured' });
continue;
}
try {
const res = await listmonkFetch(listmonkConfig, '/api/campaigns', {
method: 'POST',
body: JSON.stringify({
name: post.emailSubject,
subject: post.emailSubject,
body: post.emailHtml,
content_type: 'html',
type: 'regular',
status: 'draft',
lists: [1],
}),
});
const data = await res.json().catch(() => ({}));
newsletterResults.push({ subject: post.emailSubject, listmonkId: data?.data?.id });
} catch (e: any) {
newsletterResults.push({ subject: post.emailSubject, error: e.message });
}
}
}
// 4. Build a CampaignWorkflow DAG
const wfId = `wf-${campaign.id}`;
const wfNodes: CampaignWorkflowNode[] = [];
const wfEdges: CampaignWorkflowEdge[] = [];
// Start trigger node
const startNodeId = `node-start`;
wfNodes.push({
id: startNodeId,
type: 'campaign-start',
label: `Start: ${campaign.title}`,
position: { x: 100, y: 300 },
config: { description: `Launch ${campaign.title}` },
});
let prevNodeId = startNodeId;
let xPos = 350;
const startDate = new Date(wizard.extractedBrief?.startDate || Date.now());
for (let pi = 0; pi < campaign.phases.length; pi++) {
const phase = campaign.phases[pi];
const phasePosts = campaign.posts.filter(p => p.phase === pi + 1);
// Wait node for phase offset
const waitId = `node-wait-phase-${pi}`;
wfNodes.push({
id: waitId,
type: 'wait-duration',
label: `Wait: ${phase.label}`,
position: { x: xPos, y: 300 },
config: { amount: pi * 7, unit: 'days' },
});
wfEdges.push({
id: `edge-${prevNodeId}-${waitId}`,
fromNode: prevNodeId, fromPort: prevNodeId === startNodeId ? 'trigger' : 'done',
toNode: waitId, toPort: 'trigger',
});
xPos += 250;
// Action nodes for each post in phase
let yPos = 100;
for (const post of phasePosts) {
const actionId = `node-post-${post.id}`;
const nodeType: CampaignWorkflowNode['type'] = post.threadPosts?.length
? 'publish-thread' : post.platform === 'newsletter'
? 'send-newsletter' : 'post-to-platform';
wfNodes.push({
id: actionId,
type: nodeType,
label: `${post.platform}: ${post.phaseLabel}`,
position: { x: xPos, y: yPos },
config: {
platform: post.platform,
content: post.content,
hashtags: post.hashtags.join(' '),
...(post.threadPosts ? { threadContent: post.threadPosts.join('\n---\n') } : {}),
...(post.emailSubject ? { subject: post.emailSubject } : {}),
...(post.emailHtml ? { bodyTemplate: post.emailHtml } : {}),
},
});
wfEdges.push({
id: `edge-${waitId}-${actionId}`,
fromNode: waitId, fromPort: 'done',
toNode: actionId, toPort: 'trigger',
});
yPos += 120;
}
prevNodeId = waitId;
xPos += 250;
}
const workflow: CampaignWorkflow = {
id: wfId,
name: `${campaign.title} Workflow`,
enabled: false,
nodes: wfNodes,
edges: wfEdges,
lastRunAt: null,
lastRunStatus: null,
runCount: 0,
createdAt: now,
updatedAt: now,
};
// 5. Save workflow to SocialsDoc.campaignWorkflows
_syncServer!.changeDoc<SocialsDoc>(docId, `wizard ${id} create workflow`, (d) => {
if (!d.campaignWorkflows) d.campaignWorkflows = {} as any;
(d.campaignWorkflows as any)[wfId] = workflow;
});
// 6. Mark wizard as committed
_syncServer!.changeDoc<SocialsDoc>(docId, `wizard ${id} → committed`, (d) => {
const w = d.campaignWizards?.[id];
if (!w) return;
w.step = 'committed';
w.committedCampaignId = campaign.id;
w.updatedAt = Date.now();
});
return c.json({
ok: true,
campaignId: campaign.id,
threadIds,
workflowId: wfId,
newsletters: newsletterResults,
});
});
routes.delete("/api/campaign/wizard/:id", (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = c.get("effectiveSpace") || space;
const id = c.req.param("id");
const docId = socialsDocId(dataSpace);
const doc = ensureDoc(dataSpace);
if (!doc.campaignWizards?.[id]) return c.json({ error: "Wizard not found" }, 404);
_syncServer!.changeDoc<SocialsDoc>(docId, `abandon wizard ${id}`, (d) => {
if (d.campaignWizards?.[id]) {
d.campaignWizards[id].step = 'abandoned';
d.campaignWizards[id].updatedAt = Date.now();
}
});
return c.json({ ok: true });
});
// ── Page routes (inject web components) ──
routes.get("/campaign-wizard/:id", (c) => {
const space = c.req.param("space") || "demo";
const wizardId = c.req.param("id");
return c.html(renderShell({
title: `Campaign Wizard — rSocials | rSpace`,
moduleId: "rsocials",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-campaign-wizard space="${escapeHtml(space)}" wizard-id="${escapeHtml(wizardId)}"></folk-campaign-wizard>`,
styles: `<link rel="stylesheet" href="/modules/rsocials/campaign-wizard.css">`,
scripts: `<script type="module" src="/modules/rsocials/folk-campaign-wizard.js"></script>`,
}));
});
routes.get("/campaign-wizard", (c) => {
const space = c.req.param("space") || "demo";
return c.html(renderShell({
title: `Campaign Wizard — rSocials | rSpace`,
moduleId: "rsocials",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-campaign-wizard space="${escapeHtml(space)}"></folk-campaign-wizard>`,
styles: `<link rel="stylesheet" href="/modules/rsocials/campaign-wizard.css">`,
scripts: `<script type="module" src="/modules/rsocials/folk-campaign-wizard.js"></script>`,
}));
});
routes.get("/campaign", (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = c.get("effectiveSpace") || space;
@ -1389,6 +2002,13 @@ routes.get("/", (c) => {
<p>Plan and manage multi-platform social media campaigns</p>
</div>
</a>
<a href="${base}/campaign-wizard">
<span class="nav-icon">🧙</span>
<div class="nav-body">
<h3>Campaign Wizard</h3>
<p>AI-guided step-by-step campaign creation with progressive approval</p>
</div>
</a>
<a href="${base}/threads">
<span class="nav-icon">🧵</span>
<div class="nav-body">
@ -1461,6 +2081,18 @@ export const socialsModule: RSpaceModule = {
{ icon: "👥", title: "Team Workflow", text: "Draft, review, and approve posts collaboratively before publishing." },
],
},
{
path: "campaign-wizard",
title: "Campaign Wizard",
icon: "\uD83E\uDDD9",
tagline: "rSocials Tool",
description: "AI-guided step-by-step campaign creation. Paste a brief, review the proposed structure, approve generated content, and activate everything at once.",
features: [
{ icon: "\uD83E\uDD16", title: "AI Analysis", text: "AI extracts audience, platforms, tone, and key messages from your raw brief." },
{ icon: "\uD83D\uDCCB", title: "Progressive Approval", text: "Review and approve each step: structure, content, then full review before committing." },
{ icon: "\u26A1", title: "One-Click Activate", text: "Commits campaign, creates threads, drafts newsletters, and builds workflow DAG simultaneously." },
],
},
{
path: "threads",
title: "Posts & Threads",

View File

@ -372,6 +372,43 @@ export interface PendingApproval {
resolvedAt: number | null;
}
// ── Campaign wizard types ──
export interface ExtractedBrief {
title: string;
audience: string;
startDate: string; // ISO
endDate: string;
platforms: string[];
tone: string;
style: string;
keyMessages: string[];
}
export interface CampaignStructure {
phases: {
name: string;
label: string;
days: string;
platforms: string[];
cadence: Record<string, number>; // platform → post count
}[];
summary: string;
}
export interface CampaignWizard {
id: string;
step: 'brief' | 'structure' | 'content' | 'review' | 'committed' | 'abandoned';
rawBrief: string;
extractedBrief: ExtractedBrief | null;
structure: CampaignStructure | null;
campaignDraft: Campaign | null;
committedCampaignId: string | null;
createdAt: number;
updatedAt: number;
createdBy: string | null;
}
// ── Document root ──
export interface SocialsDoc {
@ -388,6 +425,7 @@ export interface SocialsDoc {
activeFlowId: string;
campaignWorkflows: Record<string, CampaignWorkflow>;
pendingApprovals: Record<string, PendingApproval>;
campaignWizards: Record<string, CampaignWizard>;
}
// ── Schema registration ──
@ -395,12 +433,12 @@ export interface SocialsDoc {
export const socialsSchema: DocSchema<SocialsDoc> = {
module: 'socials',
collection: 'data',
version: 5,
version: 6,
init: (): SocialsDoc => ({
meta: {
module: 'socials',
collection: 'data',
version: 5,
version: 6,
spaceSlug: '',
createdAt: Date.now(),
},
@ -410,13 +448,15 @@ export const socialsSchema: DocSchema<SocialsDoc> = {
activeFlowId: '',
campaignWorkflows: {},
pendingApprovals: {},
campaignWizards: {},
}),
migrate: (doc: SocialsDoc, _fromVersion: number): SocialsDoc => {
if (!doc.campaignFlows) (doc as any).campaignFlows = {};
if (!doc.activeFlowId) (doc as any).activeFlowId = '';
if (!doc.campaignWorkflows) (doc as any).campaignWorkflows = {};
if (!doc.pendingApprovals) (doc as any).pendingApprovals = {};
if (doc.meta) doc.meta.version = 5;
if (!doc.campaignWizards) (doc as any).campaignWizards = {};
if (doc.meta) doc.meta.version = 6;
return doc;
},
};

View File

@ -144,6 +144,8 @@ ${moduleList}
## Module Capabilities (content you can create via actions)
${moduleCapabilities}
- rsocials: create campaign (opens Campaign Wizard with pre-filled brief)
When the user asks to create a social media campaign, use create-content with module rsocials, contentType campaign, body.rawBrief set to the user's description, body.navigateToWizard true.
## Current Context
${contextSection}

View File

@ -1015,6 +1015,32 @@ export default defineConfig({
resolve(__dirname, "dist/modules/rsocials/newsletter.css"),
);
// Build campaign wizard component
await wasmBuild({
configFile: false,
root: resolve(__dirname, "modules/rsocials/components"),
build: {
emptyOutDir: false,
outDir: resolve(__dirname, "dist/modules/rsocials"),
lib: {
entry: resolve(__dirname, "modules/rsocials/components/folk-campaign-wizard.ts"),
formats: ["es"],
fileName: () => "folk-campaign-wizard.js",
},
rollupOptions: {
output: {
entryFileNames: "folk-campaign-wizard.js",
},
},
},
});
// Copy campaign wizard CSS
copyFileSync(
resolve(__dirname, "modules/rsocials/components/campaign-wizard.css"),
resolve(__dirname, "dist/modules/rsocials/campaign-wizard.css"),
);
// Build tube module component
await wasmBuild({
configFile: false,