From 154f1230dc33243c157c2653df1a49a203864e0a Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Thu, 12 Mar 2026 13:59:56 -0700 Subject: [PATCH] feat(rsocials): AI campaign generator from event brief Add "Generate from Brief" feature: paste unstructured event text, AI (Gemini 2.5 Pro) creates a full multi-phase, multi-platform campaign with threads, emojis, newsletter content, and platform-specific formatting. - Schema v4: add threadPosts, emailSubject, emailHtml to CampaignPost - New POST /api/campaign/generate endpoint with platform-aware prompting - Generate modal with platform checkboxes, tone/style selectors - Preview mode with save/regenerate/discard flow - Dynamic phase rendering (supports 3-5 AI-generated phases) - Thread badge with expandable individual posts - Newsletter platform support (icon + color) Co-Authored-By: Claude Opus 4.6 --- modules/rsocials/campaign-data.ts | 2 + .../components/folk-campaign-manager.ts | 360 +++++++++++++++--- modules/rsocials/mod.ts | 121 ++++++ modules/rsocials/schemas.ts | 9 +- 4 files changed, 441 insertions(+), 51 deletions(-) diff --git a/modules/rsocials/campaign-data.ts b/modules/rsocials/campaign-data.ts index f0500d0..2d13154 100644 --- a/modules/rsocials/campaign-data.ts +++ b/modules/rsocials/campaign-data.ts @@ -38,6 +38,7 @@ const PLATFORM_ICONS: Record = { youtube: "โ–ถ๏ธ", threads: "๐Ÿงต", bluesky: "๐Ÿฆ‹", + newsletter: "๐Ÿ“ง", }; const PLATFORM_COLORS: Record = { @@ -47,6 +48,7 @@ const PLATFORM_COLORS: Record = { youtube: "#FF0000", threads: "#000000", bluesky: "#0085FF", + newsletter: "#6366f1", }; export { PLATFORM_ICONS, PLATFORM_COLORS }; diff --git a/modules/rsocials/components/folk-campaign-manager.ts b/modules/rsocials/components/folk-campaign-manager.ts index 356c006..6892184 100644 --- a/modules/rsocials/components/folk-campaign-manager.ts +++ b/modules/rsocials/components/folk-campaign-manager.ts @@ -1,5 +1,5 @@ /** - * โ€” Campaign viewer/editor with import modal. + * โ€” Campaign viewer/editor with import modal and AI generator. * * Subscribes to Automerge doc for campaign data. Falls back to MYCOFI_CAMPAIGN * demo data when no campaigns exist or space=demo. @@ -16,12 +16,19 @@ export class FolkCampaignManager extends HTMLElement { private _campaigns: Campaign[] = []; private _offlineUnsub: (() => void) | null = null; + // AI generation state + private _generatedCampaign: Campaign | null = null; + private _previewMode = false; + private _generating = false; + private _lastBrief = ''; + // Guided tour private _tour!: TourEngine; private static readonly TOUR_STEPS = [ { target: '.campaign-header', title: "Campaign Overview", message: "This is your campaign dashboard โ€” see the title, description, platforms, and post count at a glance.", advanceOnClick: false }, { target: '.phase:first-of-type', title: "View Posts by Phase", message: "Posts are organised into phases. Each phase has a timeline and its own set of scheduled posts.", advanceOnClick: false }, { target: 'a[href*="thread-editor"]', title: "Open Thread Editor", message: "Jump to the thread editor to compose and preview tweet threads with live card preview.", advanceOnClick: true }, + { target: '#generate-btn', title: "AI Campaign Generator", message: "Paste event details and let AI create a full campaign.", advanceOnClick: true }, { target: '#import-md-btn', title: "Import from Markdown", message: "Paste tweets separated by --- to bulk-import content into the campaign.", advanceOnClick: true }, ]; @@ -104,39 +111,68 @@ export class FolkCampaignManager extends HTMLElement { return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } + private renderPostCard(post: CampaignPost): string { + const icon = PLATFORM_ICONS[post.platform] || post.platform; + const color = PLATFORM_COLORS[post.platform] || '#64748b'; + const statusClass = post.status === 'scheduled' ? 'status--scheduled' : 'status--draft'; + const date = new Date(post.scheduledAt); + const dateStr = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); + const contentPreview = post.content.length > 180 ? this.esc(post.content.substring(0, 180)) + '...' : this.esc(post.content); + const tags = post.hashtags.map(h => `#${this.esc(h)}`).join(' '); + + // Thread badge + let threadBadge = ''; + if (post.threadPosts && post.threadPosts.length > 0) { + threadBadge = `๐Ÿงต ${post.threadPosts.length}-post thread`; + } + + // Email subject for newsletter + let emailLine = ''; + if (post.emailSubject) { + emailLine = ``; + } + + // Thread expansion area + let threadExpansion = ''; + if (post.threadPosts && post.threadPosts.length > 0) { + const threadItems = post.threadPosts.map((t, i) => + `
${i + 1}. ${this.esc(t)}
` + ).join(''); + threadExpansion = ``; + } + + return ` +
+
+ ${icon} + + ${this.esc(post.status)} +
+ ${emailLine} +
Step ${post.stepNumber}
+

${contentPreview.replace(/\n/g, '
')}

+ ${threadBadge} + ${threadExpansion} + +
`; + } + private renderCampaign(c: Campaign): string { - const phases = [1, 2, 3]; - const phaseIcons = ['๐Ÿ“ฃ', '๐Ÿš€', '๐Ÿ“ก']; + // Dynamically derive phase numbers from campaign data + const phaseNumbers = [...new Set(c.posts.map(p => p.phase))].sort((a, b) => a - b); + // If no posts have phases, fall back to the phases array indices + const phases = phaseNumbers.length > 0 ? phaseNumbers : c.phases.map((_, i) => i + 1); + const phaseIcons = ['๐Ÿ“ฃ', '๐Ÿš€', '๐Ÿ“ก', '๐ŸŽฏ', '๐Ÿ“ˆ']; const phaseHTML = phases.map((phaseNum, i) => { const phasePosts = c.posts.filter(p => p.phase === phaseNum); - if (!phasePosts.length && !c.phases[i]) return ''; const phaseInfo = c.phases[i] || { label: `Phase ${phaseNum}`, days: '' }; + if (!phasePosts.length) return ''; - const postsHTML = phasePosts.map(post => { - const icon = PLATFORM_ICONS[post.platform] || post.platform; - const color = PLATFORM_COLORS[post.platform] || '#64748b'; - const statusClass = post.status === 'scheduled' ? 'status--scheduled' : 'status--draft'; - const date = new Date(post.scheduledAt); - const dateStr = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); - const contentPreview = post.content.length > 180 ? this.esc(post.content.substring(0, 180)) + '...' : this.esc(post.content); - const tags = post.hashtags.map(h => `#${this.esc(h)}`).join(' '); - - return ` -
-
- ${icon} - - ${this.esc(post.status)} -
-
Step ${post.stepNumber}
-

${contentPreview.replace(/\n/g, '
')}

- -
`; - }).join(''); + const postsHTML = phasePosts.map(post => this.renderPostCard(post)).join(''); return `
@@ -160,6 +196,7 @@ export class FolkCampaignManager extends HTMLElement {
Open Thread Editor +
@@ -167,11 +204,30 @@ export class FolkCampaignManager extends HTMLElement {
`; } + private renderPreviewBanner(): string { + return ` +
+ AI-Generated Campaign Preview +
+ + + +
+
`; + } + private render() { if (!this.shadowRoot) return; - const c = this._campaigns[0] || { ...MYCOFI_CAMPAIGN, createdAt: Date.now(), updatedAt: Date.now() }; - const campaignHTML = this.renderCampaign(c); + let contentHTML: string; + if (this._previewMode && this._generatedCampaign) { + contentHTML = this.renderPreviewBanner() + this.renderCampaign(this._generatedCampaign); + } else { + const c = this._campaigns[0] || { ...MYCOFI_CAMPAIGN, createdAt: Date.now(), updatedAt: Date.now() }; + contentHTML = this.renderCampaign(c); + } + + const allPlatforms = ['x', 'linkedin', 'instagram', 'youtube', 'threads', 'bluesky', 'newsletter']; this.shadowRoot.innerHTML = `
- ${campaignHTML} + ${contentHTML}
+ `; this.bindEvents(); @@ -282,17 +426,18 @@ export class FolkCampaignManager extends HTMLElement { // Tour button this.shadowRoot.getElementById('btn-tour')?.addEventListener('click', () => this.startTour()); - const modal = this.shadowRoot.getElementById('import-modal') as HTMLElement; - const openBtn = this.shadowRoot.getElementById('import-md-btn'); - const closeBtn = this.shadowRoot.getElementById('import-modal-close'); + // โ”€โ”€ Import modal โ”€โ”€ + const importModal = this.shadowRoot.getElementById('import-modal') as HTMLElement; + const importOpenBtn = this.shadowRoot.getElementById('import-md-btn'); + const importCloseBtn = this.shadowRoot.getElementById('import-modal-close'); const parseBtn = this.shadowRoot.getElementById('import-parse-btn'); const mdInput = this.shadowRoot.getElementById('import-md-textarea') as HTMLTextAreaElement; const platformSel = this.shadowRoot.getElementById('import-platform') as HTMLSelectElement; const importedEl = this.shadowRoot.getElementById('imported-posts'); - openBtn?.addEventListener('click', () => { modal.hidden = false; }); - closeBtn?.addEventListener('click', () => { modal.hidden = true; }); - modal?.addEventListener('click', (e) => { if (e.target === modal) modal.hidden = true; }); + importOpenBtn?.addEventListener('click', () => { importModal.hidden = false; }); + importCloseBtn?.addEventListener('click', () => { importModal.hidden = true; }); + importModal?.addEventListener('click', (e) => { if (e.target === importModal) importModal.hidden = true; }); parseBtn?.addEventListener('click', () => { const raw = mdInput.value; @@ -301,7 +446,6 @@ export class FolkCampaignManager extends HTMLElement { const platform = platformSel.value; const total = tweets.length; - // Build imported posts as campaign posts and save to Automerge const posts: CampaignPost[] = tweets.map((text, i) => ({ id: `imported-${Date.now()}-${i}`, platform, @@ -315,7 +459,6 @@ export class FolkCampaignManager extends HTMLElement { phaseLabel: 'Imported', })); - // Render imported posts inline let html = `

๐Ÿ“ฅ Imported Posts (${total})

`; html += '
'; tweets.forEach((text, i) => { @@ -332,9 +475,8 @@ export class FolkCampaignManager extends HTMLElement { }); html += '
'; importedEl.innerHTML = html; - modal.hidden = true; + importModal.hidden = true; - // Save to Automerge if runtime available if (this._space !== 'demo') { const c = this._campaigns[0]; if (c) { @@ -343,6 +485,128 @@ export class FolkCampaignManager extends HTMLElement { } } }); + + // โ”€โ”€ Generate modal โ”€โ”€ + const genModal = this.shadowRoot.getElementById('generate-modal') as HTMLElement; + const genOpenBtn = this.shadowRoot.getElementById('generate-btn'); + const genCloseBtn = this.shadowRoot.getElementById('generate-modal-close'); + const genSubmit = this.shadowRoot.getElementById('gen-submit') as HTMLButtonElement; + const genBrief = this.shadowRoot.getElementById('gen-brief') as HTMLTextAreaElement; + const genError = this.shadowRoot.getElementById('gen-error') as HTMLElement; + + genOpenBtn?.addEventListener('click', () => { + genModal.hidden = false; + // Restore last brief if regenerating + if (this._lastBrief && genBrief) genBrief.value = this._lastBrief; + }); + genCloseBtn?.addEventListener('click', () => { genModal.hidden = true; }); + genModal?.addEventListener('click', (e) => { if (e.target === genModal) genModal.hidden = true; }); + + genSubmit?.addEventListener('click', () => this.handleGenerate()); + + // โ”€โ”€ Preview mode buttons โ”€โ”€ + this.shadowRoot.getElementById('preview-save')?.addEventListener('click', () => { + if (!this._generatedCampaign) return; + this.saveCampaignToDoc(this._generatedCampaign); + this._campaigns.unshift(this._generatedCampaign); + this._generatedCampaign = null; + this._previewMode = false; + this.render(); + }); + + this.shadowRoot.getElementById('preview-discard')?.addEventListener('click', () => { + this._generatedCampaign = null; + this._previewMode = false; + this.render(); + }); + + this.shadowRoot.getElementById('preview-regenerate')?.addEventListener('click', () => { + this._previewMode = false; + this._generatedCampaign = null; + this.render(); + // Re-open generate modal after render + requestAnimationFrame(() => { + const modal = this.shadowRoot?.getElementById('generate-modal') as HTMLElement; + if (modal) modal.hidden = false; + }); + }); + + // โ”€โ”€ Thread badge toggles โ”€โ”€ + this.shadowRoot.querySelectorAll('.thread-badge').forEach(badge => { + badge.addEventListener('click', () => { + const postId = (badge as HTMLElement).dataset.postId; + if (!postId) return; + const expansion = this.shadowRoot!.getElementById(`thread-${postId}`); + if (expansion) expansion.hidden = !expansion.hidden; + }); + }); + } + + private async handleGenerate() { + if (!this.shadowRoot || this._generating) return; + + const briefEl = this.shadowRoot.getElementById('gen-brief') as HTMLTextAreaElement; + const errorEl = this.shadowRoot.getElementById('gen-error') as HTMLElement; + const submitBtn = this.shadowRoot.getElementById('gen-submit') as HTMLButtonElement; + + const brief = briefEl?.value?.trim(); + if (!brief || brief.length < 10) { + if (errorEl) errorEl.innerHTML = 'Please enter at least 10 characters describing your event.'; + return; + } + + // Gather selected platforms + const platformChecks = this.shadowRoot.querySelectorAll('#gen-platforms input[type="checkbox"]'); + const platforms: string[] = []; + platformChecks.forEach((cb: Element) => { + if ((cb as HTMLInputElement).checked) platforms.push((cb as HTMLInputElement).value); + }); + if (platforms.length === 0) { + if (errorEl) errorEl.innerHTML = 'Select at least one platform.'; + return; + } + + const tone = (this.shadowRoot.getElementById('gen-tone') as HTMLSelectElement)?.value || 'professional'; + const style = (this.shadowRoot.getElementById('gen-style') as HTMLSelectElement)?.value || 'event-promo'; + + this._generating = true; + this._lastBrief = brief; + if (errorEl) errorEl.innerHTML = ''; + if (submitBtn) { + submitBtn.disabled = true; + submitBtn.innerHTML = ' Generating...'; + } + + try { + const res = await fetch(`/${this._space}/rsocials/api/campaign/generate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ brief, platforms, tone, style }), + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({ error: 'Unknown error' })); + throw new Error(err.error || `HTTP ${res.status}`); + } + + const campaign: Campaign = await res.json(); + this._generatedCampaign = campaign; + this._previewMode = true; + + // Close modal and render preview + const genModal = this.shadowRoot.getElementById('generate-modal') as HTMLElement; + if (genModal) genModal.hidden = true; + + this.render(); + } catch (e: any) { + if (errorEl) errorEl.innerHTML = `${this.esc(e.message || 'Generation failed')}`; + } finally { + this._generating = false; + if (submitBtn) { + submitBtn.disabled = false; + submitBtn.innerHTML = 'Generate Campaign'; + } + } } } diff --git a/modules/rsocials/mod.ts b/modules/rsocials/mod.ts index 8a28daf..f790da9 100644 --- a/modules/rsocials/mod.ts +++ b/modules/rsocials/mod.ts @@ -515,6 +515,127 @@ routes.put("/api/newsletter/campaigns/:id/status", async (c) => { return c.json(data, res.status as any); }); +// โ”€โ”€ AI Campaign Generator โ”€โ”€ + +routes.post("/api/campaign/generate", async (c) => { + const GEMINI_API_KEY = process.env.GEMINI_API_KEY || ""; + if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503); + + const { brief, platforms, style, tone } = await c.req.json(); + if (!brief || typeof brief !== "string" || brief.trim().length < 10) { + return c.json({ error: "brief is required (min 10 characters)" }, 400); + } + + const selectedPlatforms = (platforms && Array.isArray(platforms) && platforms.length > 0) + ? platforms : ["x", "linkedin", "instagram", "youtube", "threads", "bluesky", "newsletter"]; + const campaignStyle = style || "event-promo"; + const campaignTone = tone || "professional"; + + const { GoogleGenerativeAI } = await import("@google/generative-ai"); + const genAI = new GoogleGenerativeAI(GEMINI_API_KEY); + const model = genAI.getGenerativeModel({ model: "gemini-2.5-pro" }); + + const systemPrompt = `You are an expert social media campaign strategist. Given an event brief, generate a complete multi-phase, multi-platform social media campaign. + +Style: ${campaignStyle} +Tone: ${campaignTone} +Target platforms: ${selectedPlatforms.join(", ")} + +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. + +Event brief: +""" +${brief.trim()} +""" + +Return ONLY valid JSON (no markdown fences): +{ + "title": "Campaign title", + "description": "1-2 sentence campaign description", + "duration": "Human-readable date range (e.g. 'Mar 20-25, 2026 (6 days)')", + "platforms": [${selectedPlatforms.map((p: string) => `"${p}"`).join(", ")}], + "phases": [ + { "name": "phase-slug", "label": "Phase Label", "days": "Day -3 to -1" } + ], + "posts": [ + { + "platform": "x", + "postType": "thread", + "stepNumber": 1, + "content": "Main post content with emojis and formatting", + "scheduledAt": "2026-03-20T09:00:00", + "status": "draft", + "hashtags": ["Tag1", "Tag2"], + "phase": 1, + "phaseLabel": "Phase Label", + "threadPosts": ["First tweet of thread", "Second tweet", "Third tweet"], + "emailSubject": null, + "emailHtml": null + } + ] +} + +Rules: +- Generate 3-5 phases based on event type (pre-launch, launch, amplification, follow-up, etc.) +- Create posts for EACH selected platform in each relevant phase (not every platform needs every phase) +- For X and Threads posts marked as "thread" postType, include threadPosts array with individual posts +- For newsletter posts, include emailSubject and emailHtml with proper HTML (inline styles, CTA button) +- Use realistic future scheduledAt dates based on the brief +- stepNumber should increment across ALL posts (global ordering) +- Each post's content must respect the platform's character limits +- Include relevant emojis naturally in post content +- Hashtags should be relevant, no # prefix in the array +- Make content engaging, not generic โ€” reference specific details from the brief`; + + try { + const result = await model.generateContent(systemPrompt); + const text = result.response.text(); + const jsonStr = text.replace(/```json\n?/g, "").replace(/```\n?/g, "").trim(); + const generated = JSON.parse(jsonStr); + + // Add IDs and timestamps + const now = Date.now(); + const campaignId = `gen-${now}`; + const campaign = { + id: campaignId, + title: generated.title || "Generated Campaign", + description: generated.description || "", + duration: generated.duration || "", + platforms: generated.platforms || selectedPlatforms, + phases: generated.phases || [], + 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, + }; + + return c.json(campaign); + } catch (e: any) { + console.error("[rSocials] Campaign generation error:", e.message); + return c.json({ error: "Failed to generate campaign: " + (e.message || "unknown error") }, 502); + } +}); + // โ”€โ”€ Campaign Workflow CRUD API โ”€โ”€ routes.get("/api/campaign-workflows", (c) => { diff --git a/modules/rsocials/schemas.ts b/modules/rsocials/schemas.ts index 646f89b..7e0bc51 100644 --- a/modules/rsocials/schemas.ts +++ b/modules/rsocials/schemas.ts @@ -36,6 +36,9 @@ export interface CampaignPost { hashtags: string[]; phase: number; phaseLabel: string; + threadPosts?: string[]; + emailSubject?: string; + emailHtml?: string; } export interface Campaign { @@ -378,12 +381,12 @@ export interface SocialsDoc { export const socialsSchema: DocSchema = { module: 'socials', collection: 'data', - version: 3, + version: 4, init: (): SocialsDoc => ({ meta: { module: 'socials', collection: 'data', - version: 3, + version: 4, spaceSlug: '', createdAt: Date.now(), }, @@ -397,7 +400,7 @@ export const socialsSchema: DocSchema = { if (!doc.campaignFlows) (doc as any).campaignFlows = {}; if (!doc.activeFlowId) (doc as any).activeFlowId = ''; if (!doc.campaignWorkflows) (doc as any).campaignWorkflows = {}; - if (doc.meta) doc.meta.version = 3; + if (doc.meta) doc.meta.version = 4; return doc; }, };