diff --git a/modules/rsocials/components/folk-newsletter-manager.ts b/modules/rsocials/components/folk-newsletter-manager.ts index fe5dce7..51f4b6c 100644 --- a/modules/rsocials/components/folk-newsletter-manager.ts +++ b/modules/rsocials/components/folk-newsletter-manager.ts @@ -1,25 +1,43 @@ /** - * — Newsletter management UI backed by Listmonk API proxy. + * — Newsletter management UI. * * Attributes: space, role * Uses EncryptID access token for auth headers. - * Three tabs: Lists, Subscribers, Campaigns. - * Full campaign editor with list selector, HTML body, live preview, schedule/send. + * + * Tabs: Drafts (always), Lists / Subscribers / Campaigns (when Listmonk configured). + * Drafts are Automerge-backed via server CRUD API. + * Listmonk tabs proxy to the external Listmonk instance. */ import { getAccessToken } from '../../../shared/components/rstack-identity'; -type Tab = 'lists' | 'subscribers' | 'campaigns'; +interface DraftData { + id: string; + title: string; + subject: string; + body: string; + status: 'draft' | 'ready' | 'sent'; + subscribers: { email: string; name?: string; addedAt: number }[]; + createdAt: number; + updatedAt: number; + createdBy: string; +} + +type Tab = 'drafts' | 'lists' | 'subscribers' | 'campaigns'; export class FolkNewsletterManager extends HTMLElement { private _space = 'demo'; private _role = 'viewer'; - private _tab: Tab = 'lists'; + private _tab: Tab = 'drafts'; private _configured = false; private _loading = true; private _error = ''; - // Data + // Drafts data + private _drafts: DraftData[] = []; + private _editingDraft: DraftData | null = null; + + // Listmonk data private _lists: any[] = []; private _subscribers: any[] = []; private _subscriberTotal = 0; @@ -27,7 +45,7 @@ export class FolkNewsletterManager extends HTMLElement { private _campaigns: any[] = []; private _showCreateForm = false; - // Editor state + // Listmonk editor state private _editingCampaign: any | null = null; private _selectedListIds: number[] = []; private _previewHtml = ''; @@ -71,10 +89,12 @@ export class FolkNewsletterManager extends HTMLElement { this._loading = true; this.render(); try { + // Always load drafts first + await this.loadDrafts(); + // Then check Listmonk status const res = await fetch(`${this.apiBase()}/status`); const data = await res.json(); this._configured = data.configured; - if (this._configured) await this.loadTab(); } catch (e: any) { this._error = e.message || 'Failed to check status'; } @@ -87,7 +107,8 @@ export class FolkNewsletterManager extends HTMLElement { this._loading = true; this.render(); try { - if (this._tab === 'lists') await this.loadLists(); + if (this._tab === 'drafts') await this.loadDrafts(); + else if (this._tab === 'lists') await this.loadLists(); else if (this._tab === 'subscribers') await this.loadSubscribers(); else if (this._tab === 'campaigns') await this.loadCampaigns(); } catch (e: any) { @@ -97,6 +118,133 @@ export class FolkNewsletterManager extends HTMLElement { this.render(); } + // ── Draft CRUD ── + + private async loadDrafts() { + const res = await this.apiFetch('/drafts'); + if (!res.ok) throw new Error(`Failed to load drafts (${res.status})`); + const data = await res.json(); + this._drafts = data.drafts || []; + } + + private async saveDraftToServer() { + const root = this.shadowRoot!; + const title = (root.querySelector('[data-field="draft-title"]') as HTMLInputElement)?.value?.trim(); + const subject = (root.querySelector('[data-field="draft-subject"]') as HTMLInputElement)?.value?.trim(); + const body = (root.querySelector('[data-field="draft-body"]') as HTMLTextAreaElement)?.value || ''; + const statusSelect = root.querySelector('[data-field="draft-status"]') as HTMLSelectElement | null; + const status = (statusSelect?.value || 'draft') as DraftData['status']; + + if (!title || !subject) { + this._error = 'Title and subject are required'; + this.render(); + return; + } + + // Collect subscribers from current editing state + const subscribers = this._editingDraft?.subscribers || []; + + try { + let res: Response; + const payload = { title, subject, body, status, subscribers }; + if (this._editingDraft?.id) { + res = await this.apiFetch(`/drafts/${this._editingDraft.id}`, { + method: 'PUT', + body: JSON.stringify(payload), + }); + } else { + res = await this.apiFetch('/drafts', { + method: 'POST', + body: JSON.stringify(payload), + }); + } + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.message || err.error || 'Failed to save draft'); + } + const data = await res.json(); + this._editingDraft = data.draft; + this._error = ''; + this.render(); + } catch (e: any) { + this._error = e.message; + this.render(); + } + } + + private async deleteDraft(id: string) { + if (!confirm('Delete this draft? This cannot be undone.')) return; + try { + const res = await this.apiFetch(`/drafts/${id}`, { method: 'DELETE' }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.message || err.error || 'Failed to delete'); + } + if (this._editingDraft?.id === id) this._editingDraft = null; + await this.loadDrafts(); + this.render(); + } catch (e: any) { + this._error = e.message; + this.render(); + } + } + + private openDraftEditor(draft?: DraftData) { + if (draft) { + this._editingDraft = { ...draft, subscribers: [...(draft.subscribers || [])] }; + } else { + this._editingDraft = { + id: '', + title: '', + subject: '', + body: '', + status: 'draft', + subscribers: [], + createdAt: 0, + updatedAt: 0, + createdBy: '', + }; + } + this._previewHtml = this._editingDraft.body; + this.render(); + } + + private closeDraftEditor() { + this._editingDraft = null; + this._previewHtml = ''; + this.loadDrafts(); + } + + private addSubscriberToDraft() { + if (!this._editingDraft) return; + const root = this.shadowRoot!; + const emailInput = root.querySelector('[data-field="sub-email"]') as HTMLInputElement; + const nameInput = root.querySelector('[data-field="sub-name"]') as HTMLInputElement; + const email = emailInput?.value?.trim(); + const name = nameInput?.value?.trim(); + if (!email || !email.includes('@')) { + this._error = 'Enter a valid email address'; + this.render(); + return; + } + if (this._editingDraft.subscribers.some(s => s.email === email)) { + this._error = 'This email is already in the list'; + this.render(); + return; + } + this._editingDraft.subscribers.push({ email, name, addedAt: Date.now() }); + this._error = ''; + this.render(); + } + + private removeSubscriberFromDraft(email: string) { + if (!this._editingDraft) return; + this._editingDraft.subscribers = this._editingDraft.subscribers.filter(s => s.email !== email); + this.render(); + } + + // ── Listmonk data loading ── + private async loadLists() { const res = await this.apiFetch('/lists'); if (!res.ok) throw new Error(`Failed to load lists (${res.status})`); @@ -119,7 +267,7 @@ export class FolkNewsletterManager extends HTMLElement { this._campaigns = data.data?.results || data.results || []; } - // ── Campaign CRUD ── + // ── Listmonk Campaign CRUD ── private async createCampaign(form: HTMLFormElement) { const fd = new FormData(form); @@ -178,7 +326,6 @@ export class FolkNewsletterManager extends HTMLElement { this._selectedListIds = []; this._previewHtml = ''; } - // Ensure lists are loaded for the selector if (this._lists.length === 0) { try { await this.loadLists(); } catch {} } @@ -192,7 +339,7 @@ export class FolkNewsletterManager extends HTMLElement { this.loadCampaigns(); } - private async saveDraft() { + private async saveListmonkDraft() { const root = this.shadowRoot!; const name = (root.querySelector('[data-field="name"]') as HTMLInputElement)?.value?.trim(); const subject = (root.querySelector('[data-field="subject"]') as HTMLInputElement)?.value?.trim(); @@ -205,9 +352,7 @@ export class FolkNewsletterManager extends HTMLElement { } const payload: any = { - name, - subject, - body, + name, subject, body, content_type: 'richtext', type: 'regular', lists: this._selectedListIds, @@ -231,7 +376,6 @@ export class FolkNewsletterManager extends HTMLElement { throw new Error(err.message || err.error || 'Failed to save'); } const data = await res.json(); - // Update editing campaign with server response (gets ID for new campaigns) this._editingCampaign = data.data || data; this._error = ''; this.render(); @@ -249,10 +393,7 @@ export class FolkNewsletterManager extends HTMLElement { const err = await res.json().catch(() => ({})); throw new Error(err.message || err.error || 'Failed to delete'); } - // If we were editing this campaign, close editor - if (this._editingCampaign?.id === id) { - this._editingCampaign = null; - } + if (this._editingCampaign?.id === id) this._editingCampaign = null; await this.loadCampaigns(); this.render(); } catch (e: any) { @@ -268,7 +409,7 @@ export class FolkNewsletterManager extends HTMLElement { return; } if (!confirm('Send this campaign now to the selected lists? This action cannot be undone.')) return; - await this.saveDraft(); + await this.saveListmonkDraft(); await this.setCampaignStatus(this._editingCampaign.id, 'running'); this.closeEditor(); } @@ -286,7 +427,7 @@ export class FolkNewsletterManager extends HTMLElement { this.render(); return; } - await this.saveDraft(); + await this.saveListmonkDraft(); await this.setCampaignStatus(this._editingCampaign.id, 'scheduled', new Date(sendAt).toISOString()); this.closeEditor(); } @@ -304,44 +445,188 @@ export class FolkNewsletterManager extends HTMLElement { } private renderBody(): string { - if (this._loading && !this._configured && this._lists.length === 0) { + if (this._loading && this._drafts.length === 0 && !this._configured) { return `
Loading...
`; } - if (!this._configured) { - return this.renderSetup(); + // Draft editor mode + if (this._editingDraft) { + return this.renderDraftEditor(); } - // Editor mode replaces normal view + // Listmonk campaign editor mode if (this._editingCampaign) { - return this.renderEditor(); + return this.renderListmonkEditor(); } + // Build tabs: Drafts always, Listmonk tabs conditionally + const listmonkTabs = this._configured ? ` + + + + ` : ''; + + const infoBanner = !this._configured ? ` +
+ Listmonk is not configured for this space. You can still create and manage newsletter drafts with built-in subscriber lists. + Configure Listmonk in space settings to enable sending. +
+ ` : ''; + return `
-

Newsletter Manager

-

Manage mailing lists, subscribers, and email campaigns via Listmonk

+

Newsletter

+

Draft newsletters, manage subscribers, and send campaigns

${this._error ? `
${this.esc(this._error)}
` : ''} + ${infoBanner}
- - - + + ${listmonkTabs}
${this._loading ? '
Loading...
' : this.renderTabContent()} `; } - private renderSetup(): string { - return ``; - } - private renderTabContent(): string { + if (this._tab === 'drafts') return this.renderDrafts(); if (this._tab === 'lists') return this.renderLists(); if (this._tab === 'subscribers') return this.renderSubscribers(); return this.renderCampaigns(); } + // ── Drafts tab ── + + private renderDrafts(): string { + const createBtn = this.isAdmin ? ` +
+ + +
+ ` : ''; + + if (this._drafts.length === 0) { + return `${createBtn}
No newsletter drafts yet. Create your first draft to get started.
`; + } + + const statusClass = (s: string) => { + if (s === 'ready') return 'active'; + if (s === 'sent') return 'finished'; + return 'draft'; + }; + + const rows = this._drafts.map(d => { + const actions: string[] = []; + if (this.isAdmin) { + actions.push(``); + actions.push(``); + } + return ` + + ${this.esc(d.title)} + ${this.esc(d.subject)} + ${this.esc(d.status)} + ${d.subscribers?.length || 0} + ${d.updatedAt ? new Date(d.updatedAt).toLocaleDateString() : '—'} + ${actions.join(' ')} + + `; + }).join(''); + + return ` + ${createBtn} + + + ${rows} +
TitleSubjectStatusSubscribersUpdatedActions
+ `; + } + + private renderDraftEditor(): string { + const d = this._editingDraft!; + const isNew = !d.id; + const title = isNew ? 'New Newsletter Draft' : `Edit: ${this.esc(d.title)}`; + + const subRows = (d.subscribers || []).map(s => ` + + ${this.esc(s.email)} + ${this.esc(s.name || '—')} + ${this.isAdmin ? `` : ''} + + `).join(''); + + const subscriberSection = ` +
+

Subscribers (${d.subscribers?.length || 0})

+ ${this.isAdmin ? ` +
+
+
+
+
+ ` : ''} + ${d.subscribers?.length ? ` + + + ${subRows} +
EmailName
+ ` : '
No subscribers added yet
'} +
+ `; + + return ` +
+ +

${title}

+
+ ${this._error ? `
${this.esc(this._error)}
` : ''} +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+
+
+ + +
+
+ + +
+
+ ${subscriberSection} +
+
+ + ${d.id ? `` : ''} +
+
+ +
+
+ `; + } + + // ── Listmonk tabs ── + private renderLists(): string { if (this._lists.length === 0) return `
No mailing lists found
`; @@ -450,7 +735,7 @@ export class FolkNewsletterManager extends HTMLElement { `; } - private renderEditor(): string { + private renderListmonkEditor(): string { const c = this._editingCampaign; const isNew = !c.id; const title = isNew ? 'New Campaign' : `Edit: ${this.esc(c.name)}`; @@ -537,20 +822,47 @@ export class FolkNewsletterManager extends HTMLElement { this.loadTab(); }); - // New campaign (opens editor) + // ── Draft actions ── + root.querySelector('[data-action="new-draft"]')?.addEventListener('click', () => { + this.openDraftEditor(); + }); + root.querySelectorAll('[data-action="edit-draft"]').forEach(btn => { + btn.addEventListener('click', () => { + const id = (btn as HTMLElement).dataset.id!; + const draft = this._drafts.find(d => d.id === id); + if (draft) this.openDraftEditor(draft); + }); + }); + root.querySelectorAll('[data-action="delete-draft"]').forEach(btn => { + btn.addEventListener('click', () => { + this.deleteDraft((btn as HTMLElement).dataset.id!); + }); + }); + root.querySelectorAll('[data-action="close-draft-editor"]').forEach(btn => { + btn.addEventListener('click', () => this.closeDraftEditor()); + }); + root.querySelector('[data-action="save-newsletter-draft"]')?.addEventListener('click', () => { + this.saveDraftToServer(); + }); + root.querySelector('[data-action="add-subscriber"]')?.addEventListener('click', () => { + this.addSubscriberToDraft(); + }); + root.querySelectorAll('[data-action="remove-sub"]').forEach(btn => { + btn.addEventListener('click', () => { + this.removeSubscriberFromDraft((btn as HTMLElement).dataset.email!); + }); + }); + + // ── Listmonk campaign actions ── root.querySelector('[data-action="new-campaign"]')?.addEventListener('click', () => { this.openEditor(); }); - - // Create campaign toggle (legacy, kept for form) root.querySelectorAll('[data-action="toggle-create"]').forEach(btn => { btn.addEventListener('click', () => { this._showCreateForm = !this._showCreateForm; this.render(); }); }); - - // Create campaign form (legacy) const form = root.querySelector('[data-form="create-campaign"]') as HTMLFormElement | null; form?.addEventListener('submit', async (e) => { e.preventDefault(); @@ -562,8 +874,6 @@ export class FolkNewsletterManager extends HTMLElement { this.render(); } }); - - // Campaign status actions root.querySelectorAll('[data-action="start-campaign"]').forEach(btn => { btn.addEventListener('click', () => this.setCampaignStatus(Number((btn as HTMLElement).dataset.id), 'running')); }); @@ -573,22 +883,16 @@ export class FolkNewsletterManager extends HTMLElement { root.querySelectorAll('[data-action="resume-campaign"]').forEach(btn => { btn.addEventListener('click', () => this.setCampaignStatus(Number((btn as HTMLElement).dataset.id), 'running')); }); - - // Edit campaign root.querySelectorAll('[data-action="edit-campaign"]').forEach(btn => { btn.addEventListener('click', () => this.openEditor(Number((btn as HTMLElement).dataset.id))); }); - - // Delete campaign root.querySelectorAll('[data-action="delete-campaign"]').forEach(btn => { btn.addEventListener('click', () => this.deleteCampaign(Number((btn as HTMLElement).dataset.id))); }); - - // Editor controls root.querySelectorAll('[data-action="close-editor"]').forEach(btn => { btn.addEventListener('click', () => this.closeEditor()); }); - root.querySelector('[data-action="save-draft"]')?.addEventListener('click', () => this.saveDraft()); + root.querySelector('[data-action="save-draft"]')?.addEventListener('click', () => this.saveListmonkDraft()); root.querySelector('[data-action="send-now"]')?.addEventListener('click', () => this.sendNow()); root.querySelector('[data-action="schedule-send"]')?.addEventListener('click', () => this.scheduleSend()); @@ -604,8 +908,8 @@ export class FolkNewsletterManager extends HTMLElement { }); }); - // Live preview update on body textarea input - const bodyTextarea = root.querySelector('[data-field="body"]') as HTMLTextAreaElement | null; + // Live preview update on body textarea input (both draft and listmonk editors) + const bodyTextarea = (root.querySelector('[data-field="draft-body"]') || root.querySelector('[data-field="body"]')) as HTMLTextAreaElement | null; bodyTextarea?.addEventListener('input', () => { clearTimeout(this._previewTimer); this._previewTimer = setTimeout(() => { @@ -614,6 +918,15 @@ export class FolkNewsletterManager extends HTMLElement { if (iframe) iframe.srcdoc = this._previewHtml; }, 300); }); + + // Allow Enter in subscriber email field to trigger add + const subEmailInput = root.querySelector('[data-field="sub-email"]') as HTMLInputElement | null; + subEmailInput?.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + this.addSubscriberToDraft(); + } + }); } private esc(s: string): string {