feat(rsocials): newsletter editor with Listmonk integration
Full campaign editor with HTML body textarea, live iframe preview, list selector with subscriber counts, save draft, send now, and schedule send. Added edit/delete actions on draft campaigns and GET/PUT/DELETE single-campaign proxy routes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
16b975cf5a
commit
df489d698c
|
|
@ -4,6 +4,7 @@
|
||||||
* 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.
|
* Three tabs: Lists, Subscribers, Campaigns.
|
||||||
|
* Full campaign editor with list selector, HTML body, live preview, schedule/send.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { getAccessToken } from '../../../shared/components/rstack-identity';
|
import { getAccessToken } from '../../../shared/components/rstack-identity';
|
||||||
|
|
@ -26,6 +27,12 @@ export class FolkNewsletterManager extends HTMLElement {
|
||||||
private _campaigns: any[] = [];
|
private _campaigns: any[] = [];
|
||||||
private _showCreateForm = false;
|
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']; }
|
static get observedAttributes() { return ['space', 'role']; }
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
|
|
@ -35,7 +42,6 @@ export class FolkNewsletterManager extends HTMLElement {
|
||||||
this.render();
|
this.render();
|
||||||
this.checkStatus();
|
this.checkStatus();
|
||||||
|
|
||||||
// Re-check status when module is configured inline
|
|
||||||
this.addEventListener('module-configured', () => {
|
this.addEventListener('module-configured', () => {
|
||||||
this._configured = false;
|
this._configured = false;
|
||||||
this._loading = true;
|
this._loading = true;
|
||||||
|
|
@ -113,6 +119,8 @@ export class FolkNewsletterManager extends HTMLElement {
|
||||||
this._campaigns = data.data?.results || data.results || [];
|
this._campaigns = data.data?.results || data.results || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Campaign CRUD ──
|
||||||
|
|
||||||
private async createCampaign(form: HTMLFormElement) {
|
private async createCampaign(form: HTMLFormElement) {
|
||||||
const fd = new FormData(form);
|
const fd = new FormData(form);
|
||||||
const body = JSON.stringify({
|
const body = JSON.stringify({
|
||||||
|
|
@ -133,10 +141,12 @@ export class FolkNewsletterManager extends HTMLElement {
|
||||||
await this.loadCampaigns();
|
await this.loadCampaigns();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async setCampaignStatus(id: number, status: string) {
|
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`, {
|
const res = await this.apiFetch(`/campaigns/${id}/status`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ status }),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({}));
|
const err = await res.json().catch(() => ({}));
|
||||||
|
|
@ -148,6 +158,139 @@ export class FolkNewsletterManager extends HTMLElement {
|
||||||
this.render();
|
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 {
|
private get isAdmin(): boolean {
|
||||||
return this._role === 'admin';
|
return this._role === 'admin';
|
||||||
}
|
}
|
||||||
|
|
@ -169,6 +312,11 @@ export class FolkNewsletterManager extends HTMLElement {
|
||||||
return this.renderSetup();
|
return this.renderSetup();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Editor mode replaces normal view
|
||||||
|
if (this._editingCampaign) {
|
||||||
|
return this.renderEditor();
|
||||||
|
}
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="nl-header">
|
<div class="nl-header">
|
||||||
<h2>Newsletter Manager</h2>
|
<h2>Newsletter Manager</h2>
|
||||||
|
|
@ -197,18 +345,22 @@ export class FolkNewsletterManager extends HTMLElement {
|
||||||
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>`;
|
||||||
|
|
||||||
const rows = this._lists.map(l => `
|
const rows = this._lists.map(l => {
|
||||||
|
const optinLabel = l.optin === 'double' ? 'double' : 'single';
|
||||||
|
return `
|
||||||
<tr>
|
<tr>
|
||||||
<td>${this.esc(l.name)}</td>
|
<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--${l.type === 'public' ? 'active' : 'draft'}">${this.esc(l.type)}</span></td>
|
||||||
<td>${l.subscriber_count ?? '—'}</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>
|
<td>${l.created_at ? new Date(l.created_at).toLocaleDateString() : '—'}</td>
|
||||||
</tr>
|
</tr>
|
||||||
`).join('');
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<table class="nl-table">
|
<table class="nl-table">
|
||||||
<thead><tr><th>Name</th><th>Type</th><th>Subscribers</th><th>Created</th></tr></thead>
|
<thead><tr><th>Name</th><th>Type</th><th>Subscribers</th><th>Opt-in</th><th>Created</th></tr></thead>
|
||||||
<tbody>${rows}</tbody>
|
<tbody>${rows}</tbody>
|
||||||
</table>
|
</table>
|
||||||
`;
|
`;
|
||||||
|
|
@ -249,32 +401,11 @@ export class FolkNewsletterManager extends HTMLElement {
|
||||||
const createBtn = this.isAdmin ? `
|
const createBtn = this.isAdmin ? `
|
||||||
<div class="nl-toolbar">
|
<div class="nl-toolbar">
|
||||||
<span></span>
|
<span></span>
|
||||||
<button class="nl-btn nl-btn--primary" data-action="toggle-create">+ New Campaign</button>
|
<button class="nl-btn nl-btn--primary" data-action="new-campaign">+ New Campaign</button>
|
||||||
</div>
|
</div>
|
||||||
` : '';
|
` : '';
|
||||||
|
|
||||||
const form = this._showCreateForm && this.isAdmin ? `
|
if (this._campaigns.length === 0) {
|
||||||
<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>`;
|
return `${createBtn}<div class="nl-empty">No campaigns found</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -284,36 +415,106 @@ export class FolkNewsletterManager extends HTMLElement {
|
||||||
};
|
};
|
||||||
|
|
||||||
const rows = this._campaigns.map(c => {
|
const rows = this._campaigns.map(c => {
|
||||||
|
const listNames = (c.lists || []).map((l: any) => this.esc(l.name)).join(', ') || '—';
|
||||||
const actions: string[] = [];
|
const actions: string[] = [];
|
||||||
if (c.status === 'draft' && this.isAdmin) {
|
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" 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') {
|
} else if (c.status === 'running') {
|
||||||
actions.push(`<button class="nl-btn nl-btn--sm" data-action="pause-campaign" data-id="${c.id}">Pause</button>`);
|
actions.push(`<button class="nl-btn nl-btn--sm" data-action="pause-campaign" data-id="${c.id}">Pause</button>`);
|
||||||
} else if (c.status === 'paused') {
|
} else if (c.status === 'paused') {
|
||||||
actions.push(`<button class="nl-btn nl-btn--sm" data-action="resume-campaign" data-id="${c.id}">Resume</button>`);
|
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 `
|
return `
|
||||||
<tr>
|
<tr>
|
||||||
<td>${this.esc(c.name)}</td>
|
<td>${this.esc(c.name)}</td>
|
||||||
<td>${this.esc(c.subject || '—')}</td>
|
<td>${this.esc(c.subject || '—')}</td>
|
||||||
<td><span class="nl-badge nl-badge--${statusLabel(c.status)}">${this.esc(c.status)}</span></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.sent ?? '—'}</td>
|
||||||
<td>${c.created_at ? new Date(c.created_at).toLocaleDateString() : '—'}</td>
|
<td>${c.created_at ? new Date(c.created_at).toLocaleDateString() : '—'}</td>
|
||||||
<td>${actions.join(' ')}</td>
|
<td class="nl-actions-cell">${actions.join(' ')}</td>
|
||||||
</tr>
|
</tr>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
return `
|
return `
|
||||||
${createBtn}
|
${createBtn}
|
||||||
${form}
|
|
||||||
<table class="nl-table">
|
<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>
|
<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>
|
<tbody>${rows}</tbody>
|
||||||
</table>
|
</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 ──
|
// ── Event listeners ──
|
||||||
|
|
||||||
private attachListeners() {
|
private attachListeners() {
|
||||||
|
|
@ -336,7 +537,12 @@ export class FolkNewsletterManager extends HTMLElement {
|
||||||
this.loadTab();
|
this.loadTab();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create campaign toggle
|
// 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 => {
|
root.querySelectorAll('[data-action="toggle-create"]').forEach(btn => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
this._showCreateForm = !this._showCreateForm;
|
this._showCreateForm = !this._showCreateForm;
|
||||||
|
|
@ -344,7 +550,7 @@ export class FolkNewsletterManager extends HTMLElement {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create campaign form
|
// 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();
|
||||||
|
|
@ -367,6 +573,47 @@ 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 => {
|
||||||
|
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 {
|
private esc(s: string): string {
|
||||||
|
|
@ -374,6 +621,10 @@ export class FolkNewsletterManager extends HTMLElement {
|
||||||
d.textContent = s;
|
d.textContent = s;
|
||||||
return d.innerHTML;
|
return d.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private escAttr(s: string): string {
|
||||||
|
return s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
customElements.define('folk-newsletter-manager', FolkNewsletterManager);
|
customElements.define('folk-newsletter-manager', FolkNewsletterManager);
|
||||||
|
|
|
||||||
|
|
@ -60,5 +60,54 @@
|
||||||
.nl-form-row { display: flex; gap: .75rem; }
|
.nl-form-row { display: flex; gap: .75rem; }
|
||||||
.nl-form-row > * { flex: 1; }
|
.nl-form-row > * { flex: 1; }
|
||||||
|
|
||||||
|
/* Danger button */
|
||||||
|
.nl-btn--danger { border-color: #ef4444; color: #f87171; }
|
||||||
|
.nl-btn--danger:hover { background: #ef444422; border-color: #f87171; }
|
||||||
|
.nl-btn--send { background: #6366f1; border-color: #6366f1; color: #fff; font-weight: 500; }
|
||||||
|
.nl-btn--send:hover { background: #4f46e5; }
|
||||||
|
|
||||||
|
/* Actions cell */
|
||||||
|
.nl-actions-cell { white-space: nowrap; }
|
||||||
|
.nl-actions-cell .nl-btn { margin-right: .25rem; }
|
||||||
|
|
||||||
/* Error */
|
/* Error */
|
||||||
.nl-error { padding: .75rem 1rem; background: #ef444422; border: 1px solid #ef4444; border-radius: 6px; color: #f87171; font-size: .85rem; margin-bottom: 1rem; }
|
.nl-error { padding: .75rem 1rem; background: #ef444422; border: 1px solid #ef4444; border-radius: 6px; color: #f87171; font-size: .85rem; margin-bottom: 1rem; }
|
||||||
|
|
||||||
|
/* ── Editor ── */
|
||||||
|
.nl-editor-header { display: flex; align-items: center; gap: 1rem; margin-bottom: 1rem; }
|
||||||
|
.nl-editor-header h2 { font-size: 1.3rem; margin: 0; }
|
||||||
|
|
||||||
|
.nl-editor-fields { margin-bottom: 1rem; }
|
||||||
|
.nl-editor-fields label { display: block; font-size: .8rem; color: #a3a3a3; margin-bottom: .25rem; }
|
||||||
|
.nl-editor-fields input { width: 100%; padding: .45rem .7rem; background: #0a0a0a; border: 1px solid #404040; border-radius: 4px; color: #e5e5e5; font-size: .85rem; margin-bottom: .75rem; box-sizing: border-box; }
|
||||||
|
|
||||||
|
/* List selector */
|
||||||
|
.nl-list-selector { display: flex; flex-wrap: wrap; gap: .5rem; padding: .5rem; background: #1e1e2e; border: 1px solid #333; border-radius: 6px; margin-bottom: 1rem; min-height: 2.5rem; align-items: center; }
|
||||||
|
.nl-list-option { display: flex; align-items: center; gap: .4rem; padding: .3rem .6rem; background: #0a0a0a; border: 1px solid #333; border-radius: 4px; cursor: pointer; font-size: .82rem; transition: border-color .15s; }
|
||||||
|
.nl-list-option:hover { border-color: #14b8a6; }
|
||||||
|
.nl-list-option input[type="checkbox"] { accent-color: #14b8a6; }
|
||||||
|
.nl-list-count { color: #a3a3a3; font-size: .75rem; margin-left: .25rem; }
|
||||||
|
|
||||||
|
/* Editor body + preview */
|
||||||
|
.nl-editor { display: flex; gap: 1rem; margin-bottom: 1rem; }
|
||||||
|
.nl-editor__body { flex: 1; display: flex; flex-direction: column; }
|
||||||
|
.nl-editor__body label { font-size: .8rem; color: #a3a3a3; margin-bottom: .25rem; }
|
||||||
|
.nl-editor__body textarea { flex: 1; min-height: 300px; padding: .6rem .8rem; background: #0a0a0a; border: 1px solid #404040; border-radius: 4px; color: #e5e5e5; font-size: .85rem; font-family: 'Fira Code', 'Cascadia Code', monospace; resize: vertical; box-sizing: border-box; line-height: 1.5; }
|
||||||
|
.nl-editor__preview { flex: 1; display: flex; flex-direction: column; }
|
||||||
|
.nl-editor__preview label { font-size: .8rem; color: #a3a3a3; margin-bottom: .25rem; }
|
||||||
|
.nl-preview-frame { flex: 1; min-height: 300px; border: 1px solid #404040; border-radius: 4px; background: #fff; }
|
||||||
|
|
||||||
|
/* Editor action bar */
|
||||||
|
.nl-editor-actions { display: flex; justify-content: space-between; align-items: center; gap: .75rem; flex-wrap: wrap; padding-top: .75rem; border-top: 1px solid #333; }
|
||||||
|
.nl-editor-actions__left, .nl-editor-actions__right { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; }
|
||||||
|
.nl-schedule-group { display: flex; align-items: center; gap: .35rem; }
|
||||||
|
.nl-schedule-group input[type="datetime-local"] { padding: .4rem .5rem; background: #0a0a0a; border: 1px solid #404040; border-radius: 4px; color: #e5e5e5; font-size: .82rem; }
|
||||||
|
|
||||||
|
/* Responsive: stack editor panes vertically on narrow screens */
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.nl-editor { flex-direction: column; }
|
||||||
|
.nl-editor-actions { flex-direction: column; align-items: stretch; }
|
||||||
|
.nl-editor-actions__left, .nl-editor-actions__right { justify-content: center; }
|
||||||
|
.nl-form-row { flex-direction: column; }
|
||||||
|
.nl-schedule-group { flex-wrap: wrap; }
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -517,6 +517,51 @@ routes.put("/api/newsletter/campaigns/:id/status", async (c) => {
|
||||||
return c.json(data, res.status as any);
|
return c.json(data, res.status as any);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Single campaign CRUD
|
||||||
|
routes.get("/api/newsletter/campaigns/:id", async (c) => {
|
||||||
|
const auth = await requireNewsletterRole(c, "moderator");
|
||||||
|
if (auth instanceof Response) return auth;
|
||||||
|
|
||||||
|
const space = c.req.param("space") || "demo";
|
||||||
|
const config = await getListmonkConfig(space);
|
||||||
|
if (!config) return c.json({ error: "Listmonk not configured" }, 404);
|
||||||
|
|
||||||
|
const campaignId = c.req.param("id");
|
||||||
|
const res = await listmonkFetch(config, `/api/campaigns/${campaignId}`);
|
||||||
|
const data = await res.json();
|
||||||
|
return c.json(data, res.status as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
routes.put("/api/newsletter/campaigns/:id", async (c) => {
|
||||||
|
const auth = await requireNewsletterRole(c, "admin");
|
||||||
|
if (auth instanceof Response) return auth;
|
||||||
|
|
||||||
|
const space = c.req.param("space") || "demo";
|
||||||
|
const config = await getListmonkConfig(space);
|
||||||
|
if (!config) return c.json({ error: "Listmonk not configured" }, 404);
|
||||||
|
|
||||||
|
const campaignId = c.req.param("id");
|
||||||
|
const body = await c.req.text();
|
||||||
|
const res = await listmonkFetch(config, `/api/campaigns/${campaignId}`, { method: "PUT", body });
|
||||||
|
const data = await res.json();
|
||||||
|
return c.json(data, res.status as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
routes.delete("/api/newsletter/campaigns/:id", async (c) => {
|
||||||
|
const auth = await requireNewsletterRole(c, "admin");
|
||||||
|
if (auth instanceof Response) return auth;
|
||||||
|
|
||||||
|
const space = c.req.param("space") || "demo";
|
||||||
|
const config = await getListmonkConfig(space);
|
||||||
|
if (!config) return c.json({ error: "Listmonk not configured" }, 404);
|
||||||
|
|
||||||
|
const campaignId = c.req.param("id");
|
||||||
|
const res = await listmonkFetch(config, `/api/campaigns/${campaignId}`, { method: "DELETE" });
|
||||||
|
if (res.status === 200 || res.status === 204) return c.json({ ok: true });
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
return c.json(data, res.status as any);
|
||||||
|
});
|
||||||
|
|
||||||
// ── Postiz API proxy routes ──
|
// ── Postiz API proxy routes ──
|
||||||
|
|
||||||
routes.get("/api/postiz/status", async (c) => {
|
routes.get("/api/postiz/status", async (c) => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue