diff --git a/modules/rsocials/components/folk-newsletter-manager.ts b/modules/rsocials/components/folk-newsletter-manager.ts
index 3057322..fe5dce7 100644
--- a/modules/rsocials/components/folk-newsletter-manager.ts
+++ b/modules/rsocials/components/folk-newsletter-manager.ts
@@ -4,6 +4,7 @@
* 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';
@@ -26,6 +27,12 @@ export class FolkNewsletterManager extends HTMLElement {
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() {
@@ -35,7 +42,6 @@ export class FolkNewsletterManager extends HTMLElement {
this.render();
this.checkStatus();
- // Re-check status when module is configured inline
this.addEventListener('module-configured', () => {
this._configured = false;
this._loading = true;
@@ -113,6 +119,8 @@ export class FolkNewsletterManager extends HTMLElement {
this._campaigns = data.data?.results || data.results || [];
}
+ // ── Campaign CRUD ──
+
private async createCampaign(form: HTMLFormElement) {
const fd = new FormData(form);
const body = JSON.stringify({
@@ -133,10 +141,12 @@ export class FolkNewsletterManager extends HTMLElement {
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`, {
method: 'PUT',
- body: JSON.stringify({ status }),
+ body: JSON.stringify(payload),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
@@ -148,6 +158,139 @@ export class FolkNewsletterManager extends HTMLElement {
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';
}
@@ -169,6 +312,11 @@ export class FolkNewsletterManager extends HTMLElement {
return this.renderSetup();
}
+ // Editor mode replaces normal view
+ if (this._editingCampaign) {
+ return this.renderEditor();
+ }
+
return `