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