rspace-online/modules/rsocials/components/folk-campaign-wizard.ts

623 lines
22 KiB
TypeScript

/**
* <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);