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:
parent
f8ab716722
commit
5ad6c6ff77
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue