diff --git a/lib/mi-action-executor.ts b/lib/mi-action-executor.ts index e5d4f9a..32fef92 100644 --- a/lib/mi-action-executor.ts +++ b/lib/mi-action-executor.ts @@ -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 }; diff --git a/lib/mi-module-routes.ts b/lib/mi-module-routes.ts index f7fbe58..d1365c3 100644 --- a/lib/mi-module-routes.ts +++ b/lib/mi-module-routes.ts @@ -28,6 +28,9 @@ export const MODULE_ROUTES: Record> = { 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" }, }, diff --git a/modules/rsocials/components/campaign-wizard.css b/modules/rsocials/components/campaign-wizard.css new file mode 100644 index 0000000..9ce55c8 --- /dev/null +++ b/modules/rsocials/components/campaign-wizard.css @@ -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; + } +} diff --git a/modules/rsocials/components/folk-campaign-wizard.ts b/modules/rsocials/components/folk-campaign-wizard.ts new file mode 100644 index 0000000..a569bec --- /dev/null +++ b/modules/rsocials/components/folk-campaign-wizard.ts @@ -0,0 +1,622 @@ +/** + * — 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; +} + +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 = { brief: 0, structure: 1, content: 2, review: 3, activate: 4, committed: 5, abandoned: -1 }; + +const PLATFORM_ICONS: Record = { + 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 = 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 = ` + +
+ ${this.renderHeader()} + ${this.renderStepIndicator()} + ${this._loading && this._step !== 'review' ? this.renderLoading() : this.renderCurrentStep()} + ${this._error ? `
${this.escHtml(this._error)}
` : ''} +
+ `; + this.bindEvents(); + } + + private renderHeader(): string { + return `
+

Campaign Wizard

+

AI-guided campaign creation with step-by-step approval

+
`; + } + + 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 ` +
  • + ${icon} + ${s.label} +
  • + ${i < STEPS.length - 1 ? `
  • ` : ''} + `; + }).join(''); + + return `
      ${items}
    `; + } + + private renderLoading(): string { + const labels: Record = { + brief: 'Analyzing your brief...', + structure: 'Generating campaign structure...', + content: 'Generating posts for each platform...', + review: 'Regenerating post...', + activate: 'Committing campaign...', + }; + return `
    ${labels[this._step] || 'Working...'}
    `; + } + + 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 `
    +

    Step 1: Paste Your Campaign Brief

    +

    Paste raw text describing your event, product launch, or campaign goals. The AI will extract key details and propose a structure.

    + +
    + + +
    +
    `; + } + + private renderStructureStep(): string { + const brief = this._extractedBrief; + const structure = this._structure; + if (!brief || !structure) return '
    No structure data. Go back to Step 1.
    '; + + const briefFields = ` +
    +
    Title
    ${this.escHtml(brief.title)}
    +
    Audience
    ${this.escHtml(brief.audience)}
    +
    Dates
    ${this.escHtml(brief.startDate)} to ${this.escHtml(brief.endDate)}
    +
    Tone / Style
    ${this.escHtml(brief.tone)} / ${this.escHtml(brief.style)}
    +
    Platforms
    ${brief.platforms.map(p => `${PLATFORM_ICONS[p] || ''} ${p}`).join(', ')}
    +
    Key Messages
    ${brief.keyMessages.map(m => `
    - ${this.escHtml(m)}
    `).join('')}
    +
    + `; + + const phases = structure.phases.map(p => ` +
    +
    + ${this.escHtml(p.label)} + ${this.escHtml(p.days)} +
    +
    + ${Object.entries(p.cadence || {}).map(([plat, count]) => + `${PLATFORM_ICONS[plat] || ''} ${plat}: ${count} post${count !== 1 ? 's' : ''}` + ).join('')} +
    +
    + `).join(''); + + return ` +
    +

    Step 2: Review Campaign Structure

    +

    ${this.escHtml(structure.summary)}

    + ${briefFields} +
    +
    +

    Proposed Phases

    +
    ${phases}
    +
    + + + +
    +
    + `; + } + + private renderContentStep(): string { + if (!this._campaignDraft) return '
    No content generated yet.
    '; + + const campaign = this._campaignDraft; + const postsByPhase = new Map(); + 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 => ` +
    +
    + ${PLATFORM_ICONS[p.platform] || ''} ${p.platform} ${this.escHtml(p.postType)} + ${p.scheduledAt?.split('T')[0] || ''} +
    +
    ${this.escHtml(p.content)}
    + ${p.threadPosts?.length ? `
    ${p.threadPosts.length} tweets in thread
    ` : ''} + ${p.hashtags?.length ? `
    #${p.hashtags.join(' #')}
    ` : ''} +
    + `).join(''); + + return ` +
    +

    ${this.escHtml(phase.label)} (${this.escHtml(phase.days)})

    +
    ${postCards}
    +
    + `; + }).join(''); + + return ` +
    +

    Step 3: Generated Content

    +

    ${campaign.posts.length} posts across ${campaign.platforms.length} platforms in ${campaign.phases.length} phases

    +
    + ${phaseSections} +
    + + + +
    + `; + } + + private renderReviewStep(): string { + if (!this._campaignDraft) return '
    No campaign to review.
    '; + + 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 ` + ${p.stepNumber} + ${PLATFORM_ICONS[p.platform] || ''} ${p.platform} + ${this.escHtml(p.phaseLabel)} + +
    ${this.escHtml(p.content)}
    + ${p.threadPosts?.length ? `
    ${p.threadPosts.length} tweets
    ` : ''} + ${p.emailSubject ? `
    Subject: ${this.escHtml(p.emailSubject)}
    ` : ''} + + ${p.scheduledAt?.split('T')[0] || '-'} + + ${isRegening + ? '
    ' + : `` + } + + `; + }).join(''); + + return ` +
    +

    Step 4: Review All Posts

    +

    Click any post content to expand. Use Regen to regenerate individual posts.

    +
    + + + + + ${rows} +
    #PlatformPhaseContentScheduled
    +
    +
    + + + +
    +
    + `; + } + + private renderActivateStep(): string { + if (this._committedCampaignId) { + const result = this._commitResult || {}; + return `
    +
    +
    \u2705
    +

    Campaign Activated!

    +

    Your campaign has been committed successfully.

    + ${result.threadIds?.length ? `

    ${result.threadIds.length} thread(s) created

    ` : ''} + ${result.newsletters?.length ? `

    ${result.newsletters.length} newsletter draft(s)

    ` : ''} + +
    +
    `; + } + + return `
    +

    Step 5: Activate

    +

    Click Activate to commit the campaign, create threads, draft newsletters, and build the workflow.

    +
    + + +
    +
    `; + } + + // ── 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); diff --git a/modules/rsocials/local-first-client.ts b/modules/rsocials/local-first-client.ts index cd378e3..86f59ab 100644 --- a/modules/rsocials/local-first-client.ts +++ b/modules/rsocials/local-first-client.ts @@ -11,7 +11,7 @@ import { EncryptedDocStore } from '../../shared/local-first/storage'; import { DocSyncManager } from '../../shared/local-first/sync'; import { DocCrypto } from '../../shared/local-first/crypto'; import { socialsSchema, socialsDocId } from './schemas'; -import type { SocialsDoc, ThreadData, Campaign, CampaignFlow, CampaignPlannerNode, CampaignEdge, 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(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(docId, `Delete campaign wizard ${id}`, (d) => { + if (d.campaignWizards?.[id]) delete d.campaignWizards[id]; + }); + } + // ── Events ── onChange(cb: (doc: SocialsDoc) => void): () => void { diff --git a/modules/rsocials/mod.ts b/modules/rsocials/mod.ts index 9ccc335..ff3faab 100644 --- a/modules/rsocials/mod.ts +++ b/modules/rsocials/mod.ts @@ -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 || '

    Newsletter content

    ', + 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(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(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(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(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(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(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(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(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(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(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(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(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: ``, + styles: ``, + scripts: ``, + })); +}); + +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: ``, + styles: ``, + scripts: ``, + })); +}); + routes.get("/campaign", (c) => { const space = c.req.param("space") || "demo"; const dataSpace = c.get("effectiveSpace") || space; @@ -1389,6 +2002,13 @@ routes.get("/", (c) => {

    Plan and manage multi-platform social media campaigns

    + + 🧙 + + 🧵