rspace-online/modules/rsocials/components/folk-newsletter-manager.ts

383 lines
12 KiB
TypeScript

/**
* <folk-newsletter-manager> — Newsletter management UI backed by Listmonk API proxy.
*
* Attributes: space, role
* Uses EncryptID access token for auth headers.
* Three tabs: Lists, Subscribers, Campaigns.
*/
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;
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();
}
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<Response> {
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 || [];
}
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') || '<p>Newsletter content</p>',
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) {
const res = await this.apiFetch(`/campaigns/${id}/status`, {
method: 'PUT',
body: JSON.stringify({ status }),
});
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 get isAdmin(): boolean {
return this._role === 'admin';
}
// ── Rendering ──
private render() {
const root = this.shadowRoot!;
root.innerHTML = `<link rel="stylesheet" href="/modules/rsocials/newsletter.css">${this.renderBody()}`;
this.attachListeners();
}
private renderBody(): string {
if (this._loading && !this._configured && this._lists.length === 0) {
return `<div class="nl-loading">Loading...</div>`;
}
if (!this._configured) {
return this.renderSetup();
}
return `
<div class="nl-header">
<h2>Newsletter Manager</h2>
<p>Manage mailing lists, subscribers, and email campaigns via Listmonk</p>
</div>
${this._error ? `<div class="nl-error">${this.esc(this._error)}</div>` : ''}
<div class="nl-tabs">
<button class="nl-tab ${this._tab === 'lists' ? 'active' : ''}" data-tab="lists">Lists</button>
<button class="nl-tab ${this._tab === 'subscribers' ? 'active' : ''}" data-tab="subscribers">Subscribers</button>
<button class="nl-tab ${this._tab === 'campaigns' ? 'active' : ''}" data-tab="campaigns">Campaigns</button>
</div>
${this._loading ? '<div class="nl-loading">Loading...</div>' : this.renderTabContent()}
`;
}
private renderSetup(): string {
return `
<div class="nl-setup">
<h3>Newsletter Not Configured</h3>
<p>Connect your Listmonk instance to manage newsletters from here.</p>
<ol class="nl-setup-steps">
<li>Open the space settings panel (gear icon in the top bar)</li>
<li>Find <strong>rSocials</strong> and click the settings gear</li>
<li>Enter your Listmonk URL, username, and password</li>
<li>Click <strong>Save Module Config</strong></li>
</ol>
</div>
`;
}
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 `<div class="nl-empty">No mailing lists found</div>`;
const rows = this._lists.map(l => `
<tr>
<td>${this.esc(l.name)}</td>
<td><span class="nl-badge nl-badge--${l.type === 'public' ? 'active' : 'draft'}">${this.esc(l.type)}</span></td>
<td>${l.subscriber_count ?? '—'}</td>
<td>${l.created_at ? new Date(l.created_at).toLocaleDateString() : '—'}</td>
</tr>
`).join('');
return `
<table class="nl-table">
<thead><tr><th>Name</th><th>Type</th><th>Subscribers</th><th>Created</th></tr></thead>
<tbody>${rows}</tbody>
</table>
`;
}
private renderSubscribers(): string {
if (this._subscribers.length === 0) return `<div class="nl-empty">No subscribers found</div>`;
const rows = this._subscribers.map(s => `
<tr>
<td>${this.esc(s.email)}</td>
<td>${this.esc(s.name || '—')}</td>
<td><span class="nl-badge nl-badge--${s.status === 'enabled' ? 'enabled' : 'blocklisted'}">${this.esc(s.status)}</span></td>
<td>${(s.lists || []).map((l: any) => this.esc(l.name)).join(', ') || '—'}</td>
<td>${s.created_at ? new Date(s.created_at).toLocaleDateString() : '—'}</td>
</tr>
`).join('');
const totalPages = Math.ceil(this._subscriberTotal / 50);
const pagination = totalPages > 1 ? `
<div class="nl-pagination">
<button class="nl-btn nl-btn--sm" data-action="prev-page" ${this._subscriberPage <= 1 ? 'disabled' : ''}>Prev</button>
<span>Page ${this._subscriberPage} of ${totalPages}</span>
<button class="nl-btn nl-btn--sm" data-action="next-page" ${this._subscriberPage >= totalPages ? 'disabled' : ''}>Next</button>
</div>
` : '';
return `
<table class="nl-table">
<thead><tr><th>Email</th><th>Name</th><th>Status</th><th>Lists</th><th>Created</th></tr></thead>
<tbody>${rows}</tbody>
</table>
${pagination}
`;
}
private renderCampaigns(): string {
const createBtn = this.isAdmin ? `
<div class="nl-toolbar">
<span></span>
<button class="nl-btn nl-btn--primary" data-action="toggle-create">+ New Campaign</button>
</div>
` : '';
const form = this._showCreateForm && this.isAdmin ? `
<form class="nl-form" data-form="create-campaign">
<div class="nl-form-row">
<div>
<label>Campaign Name</label>
<input name="name" required placeholder="My Newsletter #1" />
</div>
<div>
<label>Subject Line</label>
<input name="subject" required placeholder="This week's update" />
</div>
</div>
<label>Body (HTML)</label>
<textarea name="body" placeholder="<p>Newsletter content goes here...</p>"></textarea>
<div style="display:flex;gap:.5rem;justify-content:flex-end;margin-top:.5rem;">
<button type="button" class="nl-btn" data-action="toggle-create">Cancel</button>
<button type="submit" class="nl-btn nl-btn--primary">Create Campaign</button>
</div>
</form>
` : '';
if (this._campaigns.length === 0 && !this._showCreateForm) {
return `${createBtn}<div class="nl-empty">No campaigns found</div>`;
}
const statusLabel = (s: string) => {
const map: Record<string, string> = { draft: 'draft', running: 'running', paused: 'paused', finished: 'finished', scheduled: 'active' };
return map[s] || s;
};
const rows = this._campaigns.map(c => {
const actions: string[] = [];
if (c.status === 'draft' && this.isAdmin) {
actions.push(`<button class="nl-btn nl-btn--sm" data-action="start-campaign" data-id="${c.id}">Start</button>`);
} else if (c.status === 'running') {
actions.push(`<button class="nl-btn nl-btn--sm" data-action="pause-campaign" data-id="${c.id}">Pause</button>`);
} else if (c.status === 'paused') {
actions.push(`<button class="nl-btn nl-btn--sm" data-action="resume-campaign" data-id="${c.id}">Resume</button>`);
}
return `
<tr>
<td>${this.esc(c.name)}</td>
<td>${this.esc(c.subject || '—')}</td>
<td><span class="nl-badge nl-badge--${statusLabel(c.status)}">${this.esc(c.status)}</span></td>
<td>${c.sent ?? '—'}</td>
<td>${c.created_at ? new Date(c.created_at).toLocaleDateString() : '—'}</td>
<td>${actions.join(' ')}</td>
</tr>
`;
}).join('');
return `
${createBtn}
${form}
<table class="nl-table">
<thead><tr><th>Name</th><th>Subject</th><th>Status</th><th>Sent</th><th>Created</th><th>Actions</th></tr></thead>
<tbody>${rows}</tbody>
</table>
`;
}
// ── 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();
});
// Create campaign toggle
root.querySelectorAll('[data-action="toggle-create"]').forEach(btn => {
btn.addEventListener('click', () => {
this._showCreateForm = !this._showCreateForm;
this.render();
});
});
// Create campaign form
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'));
});
}
private esc(s: string): string {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
}
customElements.define('folk-newsletter-manager', FolkNewsletterManager);