/** * — Newsletter management UI. * * Attributes: space, role * Uses EncryptID access token for auth headers. * * 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'; 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 = 'drafts'; private _configured = false; private _loading = true; private _error = ''; // Drafts data private _drafts: DraftData[] = []; private _editingDraft: DraftData | null = null; // Listmonk data private _lists: any[] = []; private _subscribers: any[] = []; private _subscriberTotal = 0; private _subscriberPage = 1; private _campaigns: any[] = []; private _showCreateForm = false; // Listmonk editor state private _editingCampaign: any | null = null; private _selectedListIds: number[] = []; private _previewHtml = ''; private _previewTimer: any = null; static get observedAttributes() { return ['space', 'role']; } connectedCallback() { if (!this.shadowRoot) this.attachShadow({ mode: 'open' }); this._space = this.getAttribute('space') || 'demo'; this._role = this.getAttribute('role') || 'viewer'; this.render(); this.checkStatus(); this.addEventListener('module-configured', () => { this._configured = false; this._loading = true; this.render(); this.checkStatus(); }); } attributeChangedCallback(name: string, _old: string, val: string) { if (name === 'space') this._space = val; if (name === 'role') this._role = val; } private apiBase(): string { return `/${this._space}/rsocials/api/newsletter`; } private async apiFetch(path: string, opts: RequestInit = {}): Promise { const headers = new Headers(opts.headers); const token = getAccessToken(); if (token) headers.set('Authorization', `Bearer ${token}`); if (opts.body && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json'); return fetch(`${this.apiBase()}${path}`, { ...opts, headers }); } private async checkStatus() { 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; } catch (e: any) { this._error = e.message || 'Failed to check status'; } this._loading = false; this.render(); } private async loadTab() { this._error = ''; this._loading = true; this.render(); try { 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) { this._error = e.message || 'Request failed'; } this._loading = false; 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})`); const data = await res.json(); this._lists = data.data?.results || data.results || []; } private async loadSubscribers() { const res = await this.apiFetch(`/subscribers?page=${this._subscriberPage}&per_page=50`); if (!res.ok) throw new Error(`Failed to load subscribers (${res.status})`); const data = await res.json(); this._subscribers = data.data?.results || data.results || []; this._subscriberTotal = data.data?.total || data.total || 0; } private async loadCampaigns() { const res = await this.apiFetch('/campaigns'); if (!res.ok) throw new Error(`Failed to load campaigns (${res.status})`); const data = await res.json(); this._campaigns = data.data?.results || data.results || []; } // ── Listmonk Campaign CRUD ── private async createCampaign(form: HTMLFormElement) { const fd = new FormData(form); const body = JSON.stringify({ name: fd.get('name'), subject: fd.get('subject'), body: fd.get('body') || '

Newsletter content

', content_type: 'richtext', type: 'regular', lists: this._lists.length > 0 ? [this._lists[0].id] : [], }); const res = await this.apiFetch('/campaigns', { method: 'POST', body }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.message || err.error || 'Failed to create campaign'); } this._showCreateForm = false; await this.loadCampaigns(); } private async setCampaignStatus(id: number, status: string, sendAt?: string) { const payload: any = { status }; if (sendAt) payload.send_at = sendAt; const res = await this.apiFetch(`/campaigns/${id}/status`, { method: 'PUT', body: JSON.stringify(payload), }); if (!res.ok) { const err = await res.json().catch(() => ({})); this._error = err.message || err.error || 'Failed to update status'; this.render(); return; } await this.loadCampaigns(); this.render(); } private async openEditor(campaignId?: number) { if (campaignId) { this._loading = true; this.render(); try { const res = await this.apiFetch(`/campaigns/${campaignId}`); if (!res.ok) throw new Error('Failed to load campaign'); const data = await res.json(); this._editingCampaign = data.data || data; this._selectedListIds = (this._editingCampaign.lists || []).map((l: any) => l.id); this._previewHtml = this._editingCampaign.body || ''; } catch (e: any) { this._error = e.message; } this._loading = false; } else { this._editingCampaign = { name: '', subject: '', body: '', lists: [] }; this._selectedListIds = []; this._previewHtml = ''; } if (this._lists.length === 0) { try { await this.loadLists(); } catch {} } this.render(); } private closeEditor() { this._editingCampaign = null; this._selectedListIds = []; this._previewHtml = ''; this.loadCampaigns(); } 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(); const body = (root.querySelector('[data-field="body"]') as HTMLTextAreaElement)?.value || ''; if (!name || !subject) { this._error = 'Name and subject are required'; this.render(); return; } const payload: any = { name, subject, body, content_type: 'richtext', type: 'regular', lists: this._selectedListIds, }; try { let res: Response; if (this._editingCampaign?.id) { res = await this.apiFetch(`/campaigns/${this._editingCampaign.id}`, { method: 'PUT', body: JSON.stringify(payload), }); } else { res = await this.apiFetch('/campaigns', { 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'); } const data = await res.json(); this._editingCampaign = data.data || data; this._error = ''; this.render(); } catch (e: any) { this._error = e.message; this.render(); } } private async deleteCampaign(id: number) { if (!confirm('Delete this campaign? This cannot be undone.')) return; try { const res = await this.apiFetch(`/campaigns/${id}`, { method: 'DELETE' }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.message || err.error || 'Failed to delete'); } if (this._editingCampaign?.id === id) this._editingCampaign = null; await this.loadCampaigns(); this.render(); } catch (e: any) { this._error = e.message; this.render(); } } private async sendNow() { if (!this._editingCampaign?.id) { this._error = 'Save the campaign first'; this.render(); return; } if (!confirm('Send this campaign now to the selected lists? This action cannot be undone.')) return; await this.saveListmonkDraft(); await this.setCampaignStatus(this._editingCampaign.id, 'running'); this.closeEditor(); } private async scheduleSend() { if (!this._editingCampaign?.id) { this._error = 'Save the campaign first'; this.render(); return; } const input = this.shadowRoot!.querySelector('[data-field="schedule"]') as HTMLInputElement; const sendAt = input?.value; if (!sendAt) { this._error = 'Pick a date and time to schedule'; this.render(); return; } await this.saveListmonkDraft(); await this.setCampaignStatus(this._editingCampaign.id, 'scheduled', new Date(sendAt).toISOString()); this.closeEditor(); } private get isAdmin(): boolean { return this._role === 'admin'; } // ── Rendering ── private render() { const root = this.shadowRoot!; root.innerHTML = `${this.renderBody()}`; this.attachListeners(); } private renderBody(): string { if (this._loading && this._drafts.length === 0 && !this._configured) { return `
Loading...
`; } // Draft editor mode if (this._editingDraft) { return this.renderDraftEditor(); } // Listmonk campaign editor mode if (this._editingCampaign) { 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

Draft newsletters, manage subscribers, and send campaigns

${this._error ? `
${this.esc(this._error)}
` : ''} ${infoBanner}
${listmonkTabs}
${this._loading ? '
Loading...
' : this.renderTabContent()} `; } 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
`; const rows = this._lists.map(l => { const optinLabel = l.optin === 'double' ? 'double' : 'single'; return ` ${this.esc(l.name)} ${this.esc(l.type)} ${l.subscriber_count ?? 0} ${optinLabel} opt-in ${l.created_at ? new Date(l.created_at).toLocaleDateString() : '—'} `; }).join(''); return ` ${rows}
NameTypeSubscribersOpt-inCreated
`; } private renderSubscribers(): string { if (this._subscribers.length === 0) return `
No subscribers found
`; const rows = this._subscribers.map(s => ` ${this.esc(s.email)} ${this.esc(s.name || '—')} ${this.esc(s.status)} ${(s.lists || []).map((l: any) => this.esc(l.name)).join(', ') || '—'} ${s.created_at ? new Date(s.created_at).toLocaleDateString() : '—'} `).join(''); const totalPages = Math.ceil(this._subscriberTotal / 50); const pagination = totalPages > 1 ? `
Page ${this._subscriberPage} of ${totalPages}
` : ''; return ` ${rows}
EmailNameStatusListsCreated
${pagination} `; } private renderCampaigns(): string { const createBtn = this.isAdmin ? `
` : ''; if (this._campaigns.length === 0) { return `${createBtn}
No campaigns found
`; } const statusLabel = (s: string) => { const map: Record = { draft: 'draft', running: 'running', paused: 'paused', finished: 'finished', scheduled: 'active' }; return map[s] || s; }; const rows = this._campaigns.map(c => { const listNames = (c.lists || []).map((l: any) => this.esc(l.name)).join(', ') || '—'; const actions: string[] = []; if (c.status === 'draft' && this.isAdmin) { actions.push(``); actions.push(``); actions.push(``); } else if (c.status === 'running') { actions.push(``); } else if (c.status === 'paused') { actions.push(``); } else if (c.status === 'scheduled' && this.isAdmin) { actions.push(``); } return ` ${this.esc(c.name)} ${this.esc(c.subject || '—')} ${this.esc(c.status)} ${listNames} ${c.sent ?? '—'} ${c.created_at ? new Date(c.created_at).toLocaleDateString() : '—'} ${actions.join(' ')} `; }).join(''); return ` ${createBtn} ${rows}
NameSubjectStatusListsSentCreatedActions
`; } private renderListmonkEditor(): string { const c = this._editingCampaign; const isNew = !c.id; const title = isNew ? 'New Campaign' : `Edit: ${this.esc(c.name)}`; const listCheckboxes = this._lists.map(l => { const checked = this._selectedListIds.includes(l.id) ? 'checked' : ''; return ` `; }).join(''); return `

${title}

${this._error ? `
${this.esc(this._error)}
` : ''}
${listCheckboxes || 'No lists available'}
${c.id ? `` : ''}
`; } // ── Event listeners ── private attachListeners() { const root = this.shadowRoot!; // Tab switching root.querySelectorAll('.nl-tab').forEach(btn => { btn.addEventListener('click', () => { this._tab = (btn as HTMLElement).dataset.tab as Tab; this.loadTab(); }); }); // Pagination root.querySelector('[data-action="prev-page"]')?.addEventListener('click', () => { if (this._subscriberPage > 1) { this._subscriberPage--; this.loadTab(); } }); root.querySelector('[data-action="next-page"]')?.addEventListener('click', () => { this._subscriberPage++; this.loadTab(); }); // ── 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(); }); root.querySelectorAll('[data-action="toggle-create"]').forEach(btn => { btn.addEventListener('click', () => { this._showCreateForm = !this._showCreateForm; this.render(); }); }); const form = root.querySelector('[data-form="create-campaign"]') as HTMLFormElement | null; form?.addEventListener('submit', async (e) => { e.preventDefault(); try { await this.createCampaign(form); this.render(); } catch (err: any) { this._error = err.message; this.render(); } }); root.querySelectorAll('[data-action="start-campaign"]').forEach(btn => { btn.addEventListener('click', () => this.setCampaignStatus(Number((btn as HTMLElement).dataset.id), 'running')); }); root.querySelectorAll('[data-action="pause-campaign"]').forEach(btn => { btn.addEventListener('click', () => this.setCampaignStatus(Number((btn as HTMLElement).dataset.id), 'paused')); }); root.querySelectorAll('[data-action="resume-campaign"]').forEach(btn => { btn.addEventListener('click', () => this.setCampaignStatus(Number((btn as HTMLElement).dataset.id), 'running')); }); root.querySelectorAll('[data-action="edit-campaign"]').forEach(btn => { btn.addEventListener('click', () => this.openEditor(Number((btn as HTMLElement).dataset.id))); }); root.querySelectorAll('[data-action="delete-campaign"]').forEach(btn => { btn.addEventListener('click', () => this.deleteCampaign(Number((btn as HTMLElement).dataset.id))); }); root.querySelectorAll('[data-action="close-editor"]').forEach(btn => { btn.addEventListener('click', () => this.closeEditor()); }); 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()); // List selector checkboxes root.querySelectorAll('[data-list-id]').forEach(cb => { cb.addEventListener('change', () => { const id = Number((cb as HTMLElement).dataset.listId); if ((cb as HTMLInputElement).checked) { if (!this._selectedListIds.includes(id)) this._selectedListIds.push(id); } else { this._selectedListIds = this._selectedListIds.filter(x => x !== id); } }); }); // 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(() => { this._previewHtml = bodyTextarea.value; const iframe = root.querySelector('.nl-preview-frame') as HTMLIFrameElement | null; 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 { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; } private escAttr(s: string): string { return s.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>'); } } customElements.define('folk-newsletter-manager', FolkNewsletterManager);