631 lines
21 KiB
TypeScript
631 lines
21 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.
|
|
* 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<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 || [];
|
|
}
|
|
|
|
// ── 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') || '<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, 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 = `<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();
|
|
}
|
|
|
|
// Editor mode replaces normal view
|
|
if (this._editingCampaign) {
|
|
return this.renderEditor();
|
|
}
|
|
|
|
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 `<rstack-module-setup space="${this.esc(this._space)}" module-id="rsocials" role="${this.esc(this._role)}"></rstack-module-setup>`;
|
|
}
|
|
|
|
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 => {
|
|
const optinLabel = l.optin === 'double' ? 'double' : 'single';
|
|
return `
|
|
<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><span class="nl-badge nl-badge--enabled">${l.subscriber_count ?? 0}</span></td>
|
|
<td><span class="nl-badge nl-badge--${optinLabel === 'double' ? 'active' : 'draft'}">${optinLabel} opt-in</span></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>Opt-in</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="new-campaign">+ New Campaign</button>
|
|
</div>
|
|
` : '';
|
|
|
|
if (this._campaigns.length === 0) {
|
|
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 listNames = (c.lists || []).map((l: any) => this.esc(l.name)).join(', ') || '—';
|
|
const actions: string[] = [];
|
|
if (c.status === 'draft' && this.isAdmin) {
|
|
actions.push(`<button class="nl-btn nl-btn--sm" data-action="edit-campaign" data-id="${c.id}">Edit</button>`);
|
|
actions.push(`<button class="nl-btn nl-btn--sm" data-action="start-campaign" data-id="${c.id}">Start</button>`);
|
|
actions.push(`<button class="nl-btn nl-btn--sm nl-btn--danger" data-action="delete-campaign" data-id="${c.id}">Delete</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>`);
|
|
} else if (c.status === 'scheduled' && this.isAdmin) {
|
|
actions.push(`<button class="nl-btn nl-btn--sm" data-action="edit-campaign" data-id="${c.id}">Edit</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>${listNames}</td>
|
|
<td>${c.sent ?? '—'}</td>
|
|
<td>${c.created_at ? new Date(c.created_at).toLocaleDateString() : '—'}</td>
|
|
<td class="nl-actions-cell">${actions.join(' ')}</td>
|
|
</tr>
|
|
`;
|
|
}).join('');
|
|
|
|
return `
|
|
${createBtn}
|
|
<table class="nl-table">
|
|
<thead><tr><th>Name</th><th>Subject</th><th>Status</th><th>Lists</th><th>Sent</th><th>Created</th><th>Actions</th></tr></thead>
|
|
<tbody>${rows}</tbody>
|
|
</table>
|
|
`;
|
|
}
|
|
|
|
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 `
|
|
<label class="nl-list-option">
|
|
<input type="checkbox" data-list-id="${l.id}" ${checked} />
|
|
<span>${this.esc(l.name)}</span>
|
|
<span class="nl-list-count">${l.subscriber_count ?? 0}</span>
|
|
</label>
|
|
`;
|
|
}).join('');
|
|
|
|
return `
|
|
<div class="nl-editor-header">
|
|
<button class="nl-btn" data-action="close-editor">← Back</button>
|
|
<h2>${title}</h2>
|
|
</div>
|
|
${this._error ? `<div class="nl-error">${this.esc(this._error)}</div>` : ''}
|
|
<div class="nl-editor-fields">
|
|
<div class="nl-form-row">
|
|
<div>
|
|
<label>Campaign Name</label>
|
|
<input data-field="name" value="${this.escAttr(c.name || '')}" placeholder="My Newsletter #1" />
|
|
</div>
|
|
<div>
|
|
<label>Subject Line</label>
|
|
<input data-field="subject" value="${this.escAttr(c.subject || '')}" placeholder="This week's update" />
|
|
</div>
|
|
</div>
|
|
<label>Target Lists</label>
|
|
<div class="nl-list-selector">
|
|
${listCheckboxes || '<span class="nl-empty">No lists available</span>'}
|
|
</div>
|
|
</div>
|
|
<div class="nl-editor">
|
|
<div class="nl-editor__body">
|
|
<label>Body (HTML)</label>
|
|
<textarea data-field="body" placeholder="<p>Newsletter content goes here...</p>">${this.esc(c.body || '')}</textarea>
|
|
</div>
|
|
<div class="nl-editor__preview">
|
|
<label>Preview</label>
|
|
<iframe class="nl-preview-frame" sandbox="allow-same-origin" srcdoc="${this.escAttr(this._previewHtml || c.body || '')}"></iframe>
|
|
</div>
|
|
</div>
|
|
<div class="nl-editor-actions">
|
|
<div class="nl-editor-actions__left">
|
|
<button class="nl-btn" data-action="close-editor">Cancel</button>
|
|
${c.id ? `<button class="nl-btn nl-btn--danger" data-action="delete-campaign" data-id="${c.id}">Delete</button>` : ''}
|
|
</div>
|
|
<div class="nl-editor-actions__right">
|
|
<button class="nl-btn nl-btn--primary" data-action="save-draft">Save Draft</button>
|
|
<div class="nl-schedule-group">
|
|
<input type="datetime-local" data-field="schedule" />
|
|
<button class="nl-btn" data-action="schedule-send">Schedule</button>
|
|
</div>
|
|
<button class="nl-btn nl-btn--send" data-action="send-now">Send Now</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// ── 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, '<').replace(/>/g, '>');
|
|
}
|
|
}
|
|
|
|
customElements.define('folk-newsletter-manager', FolkNewsletterManager);
|