Merge branch 'dev'
This commit is contained in:
commit
f28e80c323
|
|
@ -1,25 +1,43 @@
|
||||||
/**
|
/**
|
||||||
* <folk-newsletter-manager> — Newsletter management UI backed by Listmonk API proxy.
|
* <folk-newsletter-manager> — Newsletter management UI.
|
||||||
*
|
*
|
||||||
* Attributes: space, role
|
* Attributes: space, role
|
||||||
* Uses EncryptID access token for auth headers.
|
* 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';
|
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 {
|
export class FolkNewsletterManager extends HTMLElement {
|
||||||
private _space = 'demo';
|
private _space = 'demo';
|
||||||
private _role = 'viewer';
|
private _role = 'viewer';
|
||||||
private _tab: Tab = 'lists';
|
private _tab: Tab = 'drafts';
|
||||||
private _configured = false;
|
private _configured = false;
|
||||||
private _loading = true;
|
private _loading = true;
|
||||||
private _error = '';
|
private _error = '';
|
||||||
|
|
||||||
// Data
|
// Drafts data
|
||||||
|
private _drafts: DraftData[] = [];
|
||||||
|
private _editingDraft: DraftData | null = null;
|
||||||
|
|
||||||
|
// Listmonk data
|
||||||
private _lists: any[] = [];
|
private _lists: any[] = [];
|
||||||
private _subscribers: any[] = [];
|
private _subscribers: any[] = [];
|
||||||
private _subscriberTotal = 0;
|
private _subscriberTotal = 0;
|
||||||
|
|
@ -27,7 +45,7 @@ export class FolkNewsletterManager extends HTMLElement {
|
||||||
private _campaigns: any[] = [];
|
private _campaigns: any[] = [];
|
||||||
private _showCreateForm = false;
|
private _showCreateForm = false;
|
||||||
|
|
||||||
// Editor state
|
// Listmonk editor state
|
||||||
private _editingCampaign: any | null = null;
|
private _editingCampaign: any | null = null;
|
||||||
private _selectedListIds: number[] = [];
|
private _selectedListIds: number[] = [];
|
||||||
private _previewHtml = '';
|
private _previewHtml = '';
|
||||||
|
|
@ -71,10 +89,12 @@ export class FolkNewsletterManager extends HTMLElement {
|
||||||
this._loading = true;
|
this._loading = true;
|
||||||
this.render();
|
this.render();
|
||||||
try {
|
try {
|
||||||
|
// Always load drafts first
|
||||||
|
await this.loadDrafts();
|
||||||
|
// Then check Listmonk status
|
||||||
const res = await fetch(`${this.apiBase()}/status`);
|
const res = await fetch(`${this.apiBase()}/status`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
this._configured = data.configured;
|
this._configured = data.configured;
|
||||||
if (this._configured) await this.loadTab();
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
this._error = e.message || 'Failed to check status';
|
this._error = e.message || 'Failed to check status';
|
||||||
}
|
}
|
||||||
|
|
@ -87,7 +107,8 @@ export class FolkNewsletterManager extends HTMLElement {
|
||||||
this._loading = true;
|
this._loading = true;
|
||||||
this.render();
|
this.render();
|
||||||
try {
|
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 === 'subscribers') await this.loadSubscribers();
|
||||||
else if (this._tab === 'campaigns') await this.loadCampaigns();
|
else if (this._tab === 'campaigns') await this.loadCampaigns();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|
@ -97,6 +118,133 @@ export class FolkNewsletterManager extends HTMLElement {
|
||||||
this.render();
|
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() {
|
private async loadLists() {
|
||||||
const res = await this.apiFetch('/lists');
|
const res = await this.apiFetch('/lists');
|
||||||
if (!res.ok) throw new Error(`Failed to load lists (${res.status})`);
|
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 || [];
|
this._campaigns = data.data?.results || data.results || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Campaign CRUD ──
|
// ── Listmonk Campaign CRUD ──
|
||||||
|
|
||||||
private async createCampaign(form: HTMLFormElement) {
|
private async createCampaign(form: HTMLFormElement) {
|
||||||
const fd = new FormData(form);
|
const fd = new FormData(form);
|
||||||
|
|
@ -178,7 +326,6 @@ export class FolkNewsletterManager extends HTMLElement {
|
||||||
this._selectedListIds = [];
|
this._selectedListIds = [];
|
||||||
this._previewHtml = '';
|
this._previewHtml = '';
|
||||||
}
|
}
|
||||||
// Ensure lists are loaded for the selector
|
|
||||||
if (this._lists.length === 0) {
|
if (this._lists.length === 0) {
|
||||||
try { await this.loadLists(); } catch {}
|
try { await this.loadLists(); } catch {}
|
||||||
}
|
}
|
||||||
|
|
@ -192,7 +339,7 @@ export class FolkNewsletterManager extends HTMLElement {
|
||||||
this.loadCampaigns();
|
this.loadCampaigns();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async saveDraft() {
|
private async saveListmonkDraft() {
|
||||||
const root = this.shadowRoot!;
|
const root = this.shadowRoot!;
|
||||||
const name = (root.querySelector('[data-field="name"]') as HTMLInputElement)?.value?.trim();
|
const name = (root.querySelector('[data-field="name"]') as HTMLInputElement)?.value?.trim();
|
||||||
const subject = (root.querySelector('[data-field="subject"]') 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 = {
|
const payload: any = {
|
||||||
name,
|
name, subject, body,
|
||||||
subject,
|
|
||||||
body,
|
|
||||||
content_type: 'richtext',
|
content_type: 'richtext',
|
||||||
type: 'regular',
|
type: 'regular',
|
||||||
lists: this._selectedListIds,
|
lists: this._selectedListIds,
|
||||||
|
|
@ -231,7 +376,6 @@ export class FolkNewsletterManager extends HTMLElement {
|
||||||
throw new Error(err.message || err.error || 'Failed to save');
|
throw new Error(err.message || err.error || 'Failed to save');
|
||||||
}
|
}
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
// Update editing campaign with server response (gets ID for new campaigns)
|
|
||||||
this._editingCampaign = data.data || data;
|
this._editingCampaign = data.data || data;
|
||||||
this._error = '';
|
this._error = '';
|
||||||
this.render();
|
this.render();
|
||||||
|
|
@ -249,10 +393,7 @@ export class FolkNewsletterManager extends HTMLElement {
|
||||||
const err = await res.json().catch(() => ({}));
|
const err = await res.json().catch(() => ({}));
|
||||||
throw new Error(err.message || err.error || 'Failed to delete');
|
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();
|
await this.loadCampaigns();
|
||||||
this.render();
|
this.render();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|
@ -268,7 +409,7 @@ export class FolkNewsletterManager extends HTMLElement {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!confirm('Send this campaign now to the selected lists? This action cannot be undone.')) 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');
|
await this.setCampaignStatus(this._editingCampaign.id, 'running');
|
||||||
this.closeEditor();
|
this.closeEditor();
|
||||||
}
|
}
|
||||||
|
|
@ -286,7 +427,7 @@ export class FolkNewsletterManager extends HTMLElement {
|
||||||
this.render();
|
this.render();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this.saveDraft();
|
await this.saveListmonkDraft();
|
||||||
await this.setCampaignStatus(this._editingCampaign.id, 'scheduled', new Date(sendAt).toISOString());
|
await this.setCampaignStatus(this._editingCampaign.id, 'scheduled', new Date(sendAt).toISOString());
|
||||||
this.closeEditor();
|
this.closeEditor();
|
||||||
}
|
}
|
||||||
|
|
@ -304,44 +445,188 @@ export class FolkNewsletterManager extends HTMLElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderBody(): string {
|
private renderBody(): string {
|
||||||
if (this._loading && !this._configured && this._lists.length === 0) {
|
if (this._loading && this._drafts.length === 0 && !this._configured) {
|
||||||
return `<div class="nl-loading">Loading...</div>`;
|
return `<div class="nl-loading">Loading...</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this._configured) {
|
// Draft editor mode
|
||||||
return this.renderSetup();
|
if (this._editingDraft) {
|
||||||
|
return this.renderDraftEditor();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Editor mode replaces normal view
|
// Listmonk campaign editor mode
|
||||||
if (this._editingCampaign) {
|
if (this._editingCampaign) {
|
||||||
return this.renderEditor();
|
return this.renderListmonkEditor();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build tabs: Drafts always, Listmonk tabs conditionally
|
||||||
|
const listmonkTabs = this._configured ? `
|
||||||
|
<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>
|
||||||
|
` : '';
|
||||||
|
|
||||||
|
const infoBanner = !this._configured ? `
|
||||||
|
<div class="nl-info-banner">
|
||||||
|
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.
|
||||||
|
</div>
|
||||||
|
` : '';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="nl-header">
|
<div class="nl-header">
|
||||||
<h2>Newsletter Manager</h2>
|
<h2>Newsletter</h2>
|
||||||
<p>Manage mailing lists, subscribers, and email campaigns via Listmonk</p>
|
<p>Draft newsletters, manage subscribers, and send campaigns</p>
|
||||||
</div>
|
</div>
|
||||||
${this._error ? `<div class="nl-error">${this.esc(this._error)}</div>` : ''}
|
${this._error ? `<div class="nl-error">${this.esc(this._error)}</div>` : ''}
|
||||||
|
${infoBanner}
|
||||||
<div class="nl-tabs">
|
<div class="nl-tabs">
|
||||||
<button class="nl-tab ${this._tab === 'lists' ? 'active' : ''}" data-tab="lists">Lists</button>
|
<button class="nl-tab ${this._tab === 'drafts' ? 'active' : ''}" data-tab="drafts">Drafts</button>
|
||||||
<button class="nl-tab ${this._tab === 'subscribers' ? 'active' : ''}" data-tab="subscribers">Subscribers</button>
|
${listmonkTabs}
|
||||||
<button class="nl-tab ${this._tab === 'campaigns' ? 'active' : ''}" data-tab="campaigns">Campaigns</button>
|
|
||||||
</div>
|
</div>
|
||||||
${this._loading ? '<div class="nl-loading">Loading...</div>' : this.renderTabContent()}
|
${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 {
|
private renderTabContent(): string {
|
||||||
|
if (this._tab === 'drafts') return this.renderDrafts();
|
||||||
if (this._tab === 'lists') return this.renderLists();
|
if (this._tab === 'lists') return this.renderLists();
|
||||||
if (this._tab === 'subscribers') return this.renderSubscribers();
|
if (this._tab === 'subscribers') return this.renderSubscribers();
|
||||||
return this.renderCampaigns();
|
return this.renderCampaigns();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Drafts tab ──
|
||||||
|
|
||||||
|
private renderDrafts(): string {
|
||||||
|
const createBtn = this.isAdmin ? `
|
||||||
|
<div class="nl-toolbar">
|
||||||
|
<span></span>
|
||||||
|
<button class="nl-btn nl-btn--primary" data-action="new-draft">+ New Draft</button>
|
||||||
|
</div>
|
||||||
|
` : '';
|
||||||
|
|
||||||
|
if (this._drafts.length === 0) {
|
||||||
|
return `${createBtn}<div class="nl-empty">No newsletter drafts yet. Create your first draft to get started.</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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(`<button class="nl-btn nl-btn--sm" data-action="edit-draft" data-id="${d.id}">Edit</button>`);
|
||||||
|
actions.push(`<button class="nl-btn nl-btn--sm nl-btn--danger" data-action="delete-draft" data-id="${d.id}">Delete</button>`);
|
||||||
|
}
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td>${this.esc(d.title)}</td>
|
||||||
|
<td>${this.esc(d.subject)}</td>
|
||||||
|
<td><span class="nl-badge nl-badge--${statusClass(d.status)}">${this.esc(d.status)}</span></td>
|
||||||
|
<td>${d.subscribers?.length || 0}</td>
|
||||||
|
<td>${d.updatedAt ? new Date(d.updatedAt).toLocaleDateString() : '—'}</td>
|
||||||
|
<td class="nl-actions-cell">${actions.join(' ')}</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
return `
|
||||||
|
${createBtn}
|
||||||
|
<table class="nl-table">
|
||||||
|
<thead><tr><th>Title</th><th>Subject</th><th>Status</th><th>Subscribers</th><th>Updated</th><th>Actions</th></tr></thead>
|
||||||
|
<tbody>${rows}</tbody>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 => `
|
||||||
|
<tr>
|
||||||
|
<td>${this.esc(s.email)}</td>
|
||||||
|
<td>${this.esc(s.name || '—')}</td>
|
||||||
|
<td>${this.isAdmin ? `<button class="nl-btn nl-btn--sm nl-btn--danger" data-action="remove-sub" data-email="${this.escAttr(s.email)}">Remove</button>` : ''}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
const subscriberSection = `
|
||||||
|
<div class="nl-draft-subscribers">
|
||||||
|
<h3>Subscribers (${d.subscribers?.length || 0})</h3>
|
||||||
|
${this.isAdmin ? `
|
||||||
|
<div class="nl-form-row nl-sub-add-row">
|
||||||
|
<div><input data-field="sub-email" placeholder="email@example.com" type="email" /></div>
|
||||||
|
<div><input data-field="sub-name" placeholder="Name (optional)" /></div>
|
||||||
|
<div><button class="nl-btn nl-btn--sm nl-btn--primary" data-action="add-subscriber">Add</button></div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
${d.subscribers?.length ? `
|
||||||
|
<table class="nl-table nl-table--compact">
|
||||||
|
<thead><tr><th>Email</th><th>Name</th><th></th></tr></thead>
|
||||||
|
<tbody>${subRows}</tbody>
|
||||||
|
</table>
|
||||||
|
` : '<div class="nl-empty">No subscribers added yet</div>'}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="nl-editor-header">
|
||||||
|
<button class="nl-btn" data-action="close-draft-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>Title</label>
|
||||||
|
<input data-field="draft-title" value="${this.escAttr(d.title || '')}" placeholder="Newsletter title" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Subject Line</label>
|
||||||
|
<input data-field="draft-subject" value="${this.escAttr(d.subject || '')}" placeholder="Email subject" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="nl-form-row">
|
||||||
|
<div>
|
||||||
|
<label>Status</label>
|
||||||
|
<select data-field="draft-status">
|
||||||
|
<option value="draft" ${d.status === 'draft' ? 'selected' : ''}>Draft</option>
|
||||||
|
<option value="ready" ${d.status === 'ready' ? 'selected' : ''}>Ready</option>
|
||||||
|
<option value="sent" ${d.status === 'sent' ? 'selected' : ''}>Sent</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="nl-editor">
|
||||||
|
<div class="nl-editor__body">
|
||||||
|
<label>Body (HTML)</label>
|
||||||
|
<textarea data-field="draft-body" placeholder="<p>Newsletter content goes here...</p>">${this.esc(d.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 || d.body || '')}"></iframe>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${subscriberSection}
|
||||||
|
<div class="nl-editor-actions">
|
||||||
|
<div class="nl-editor-actions__left">
|
||||||
|
<button class="nl-btn" data-action="close-draft-editor">Cancel</button>
|
||||||
|
${d.id ? `<button class="nl-btn nl-btn--danger" data-action="delete-draft" data-id="${d.id}">Delete</button>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="nl-editor-actions__right">
|
||||||
|
<button class="nl-btn nl-btn--primary" data-action="save-newsletter-draft">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Listmonk tabs ──
|
||||||
|
|
||||||
private renderLists(): string {
|
private renderLists(): string {
|
||||||
if (this._lists.length === 0) return `<div class="nl-empty">No mailing lists found</div>`;
|
if (this._lists.length === 0) return `<div class="nl-empty">No mailing lists found</div>`;
|
||||||
|
|
||||||
|
|
@ -450,7 +735,7 @@ export class FolkNewsletterManager extends HTMLElement {
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderEditor(): string {
|
private renderListmonkEditor(): string {
|
||||||
const c = this._editingCampaign;
|
const c = this._editingCampaign;
|
||||||
const isNew = !c.id;
|
const isNew = !c.id;
|
||||||
const title = isNew ? 'New Campaign' : `Edit: ${this.esc(c.name)}`;
|
const title = isNew ? 'New Campaign' : `Edit: ${this.esc(c.name)}`;
|
||||||
|
|
@ -537,20 +822,47 @@ export class FolkNewsletterManager extends HTMLElement {
|
||||||
this.loadTab();
|
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', () => {
|
root.querySelector('[data-action="new-campaign"]')?.addEventListener('click', () => {
|
||||||
this.openEditor();
|
this.openEditor();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create campaign toggle (legacy, kept for form)
|
|
||||||
root.querySelectorAll('[data-action="toggle-create"]').forEach(btn => {
|
root.querySelectorAll('[data-action="toggle-create"]').forEach(btn => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
this._showCreateForm = !this._showCreateForm;
|
this._showCreateForm = !this._showCreateForm;
|
||||||
this.render();
|
this.render();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create campaign form (legacy)
|
|
||||||
const form = root.querySelector('[data-form="create-campaign"]') as HTMLFormElement | null;
|
const form = root.querySelector('[data-form="create-campaign"]') as HTMLFormElement | null;
|
||||||
form?.addEventListener('submit', async (e) => {
|
form?.addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -562,8 +874,6 @@ export class FolkNewsletterManager extends HTMLElement {
|
||||||
this.render();
|
this.render();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Campaign status actions
|
|
||||||
root.querySelectorAll('[data-action="start-campaign"]').forEach(btn => {
|
root.querySelectorAll('[data-action="start-campaign"]').forEach(btn => {
|
||||||
btn.addEventListener('click', () => this.setCampaignStatus(Number((btn as HTMLElement).dataset.id), 'running'));
|
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 => {
|
root.querySelectorAll('[data-action="resume-campaign"]').forEach(btn => {
|
||||||
btn.addEventListener('click', () => this.setCampaignStatus(Number((btn as HTMLElement).dataset.id), 'running'));
|
btn.addEventListener('click', () => this.setCampaignStatus(Number((btn as HTMLElement).dataset.id), 'running'));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Edit campaign
|
|
||||||
root.querySelectorAll('[data-action="edit-campaign"]').forEach(btn => {
|
root.querySelectorAll('[data-action="edit-campaign"]').forEach(btn => {
|
||||||
btn.addEventListener('click', () => this.openEditor(Number((btn as HTMLElement).dataset.id)));
|
btn.addEventListener('click', () => this.openEditor(Number((btn as HTMLElement).dataset.id)));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete campaign
|
|
||||||
root.querySelectorAll('[data-action="delete-campaign"]').forEach(btn => {
|
root.querySelectorAll('[data-action="delete-campaign"]').forEach(btn => {
|
||||||
btn.addEventListener('click', () => this.deleteCampaign(Number((btn as HTMLElement).dataset.id)));
|
btn.addEventListener('click', () => this.deleteCampaign(Number((btn as HTMLElement).dataset.id)));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Editor controls
|
|
||||||
root.querySelectorAll('[data-action="close-editor"]').forEach(btn => {
|
root.querySelectorAll('[data-action="close-editor"]').forEach(btn => {
|
||||||
btn.addEventListener('click', () => this.closeEditor());
|
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="send-now"]')?.addEventListener('click', () => this.sendNow());
|
||||||
root.querySelector('[data-action="schedule-send"]')?.addEventListener('click', () => this.scheduleSend());
|
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
|
// Live preview update on body textarea input (both draft and listmonk editors)
|
||||||
const bodyTextarea = root.querySelector('[data-field="body"]') as HTMLTextAreaElement | null;
|
const bodyTextarea = (root.querySelector('[data-field="draft-body"]') || root.querySelector('[data-field="body"]')) as HTMLTextAreaElement | null;
|
||||||
bodyTextarea?.addEventListener('input', () => {
|
bodyTextarea?.addEventListener('input', () => {
|
||||||
clearTimeout(this._previewTimer);
|
clearTimeout(this._previewTimer);
|
||||||
this._previewTimer = setTimeout(() => {
|
this._previewTimer = setTimeout(() => {
|
||||||
|
|
@ -614,6 +918,15 @@ export class FolkNewsletterManager extends HTMLElement {
|
||||||
if (iframe) iframe.srcdoc = this._previewHtml;
|
if (iframe) iframe.srcdoc = this._previewHtml;
|
||||||
}, 300);
|
}, 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 {
|
private esc(s: string): string {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue