/** * — Newsletter management UI backed by Listmonk API proxy. * * 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. */ import { getAccessToken } from '../../../shared/components/rstack-identity'; type Tab = 'lists' | 'subscribers' | 'campaigns'; export class FolkNewsletterManager extends HTMLElement { private _space = 'demo'; private _role = 'viewer'; private _tab: Tab = 'lists'; private _configured = false; private _loading = true; private _error = ''; // Data private _lists: any[] = []; private _subscribers: any[] = []; private _subscriberTotal = 0; private _subscriberPage = 1; private _campaigns: any[] = []; private _showCreateForm = false; // 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 { 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'; } this._loading = false; this.render(); } private async loadTab() { this._error = ''; this._loading = true; this.render(); try { 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(); } 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 || []; } // ── 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 = ''; } // Ensure lists are loaded for the selector 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 saveDraft() { 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(); // Update editing campaign with server response (gets ID for new campaigns) 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 we were editing this campaign, close editor 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.saveDraft(); 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.saveDraft(); 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._configured && this._lists.length === 0) { return `
Loading...
`; } if (!this._configured) { return this.renderSetup(); } // Editor mode replaces normal view if (this._editingCampaign) { return this.renderEditor(); } return `

Newsletter Manager

Manage mailing lists, subscribers, and email campaigns via Listmonk

${this._error ? `
${this.esc(this._error)}
` : ''}
${this._loading ? '
Loading...
' : this.renderTabContent()} `; } private renderSetup(): string { return ``; } private renderTabContent(): string { if (this._tab === 'lists') return this.renderLists(); if (this._tab === 'subscribers') return this.renderSubscribers(); return this.renderCampaigns(); } 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 renderEditor(): 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(); }); // New campaign (opens editor) 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(); try { await this.createCampaign(form); this.render(); } catch (err: any) { this._error = err.message; 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')); }); 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')); }); // 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="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 const bodyTextarea = 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); }); } 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);