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 `

Newsletter Manager

@@ -197,18 +345,22 @@ export class FolkNewsletterManager extends HTMLElement { private renderLists(): string { if (this._lists.length === 0) return `
No mailing lists found
`; - const rows = this._lists.map(l => ` - - ${this.esc(l.name)} - ${this.esc(l.type)} - ${l.subscriber_count ?? '—'} - ${l.created_at ? new Date(l.created_at).toLocaleDateString() : '—'} - - `).join(''); + const rows = this._lists.map(l => { + const optinLabel = l.optin === 'double' ? 'double' : 'single'; + return ` + + ${this.esc(l.name)} + ${this.esc(l.type)} + ${l.subscriber_count ?? 0} + ${optinLabel} opt-in + ${l.created_at ? new Date(l.created_at).toLocaleDateString() : '—'} + + `; + }).join(''); return ` - + ${rows}
NameTypeSubscribersCreated
NameTypeSubscribersOpt-inCreated
`; @@ -249,32 +401,11 @@ export class FolkNewsletterManager extends HTMLElement { const createBtn = this.isAdmin ? `
- +
` : ''; - const form = this._showCreateForm && this.isAdmin ? ` -
-
-
- - -
-
- - -
-
- - -
- - -
-
- ` : ''; - - if (this._campaigns.length === 0 && !this._showCreateForm) { + if (this._campaigns.length === 0) { return `${createBtn}
No campaigns found
`; } @@ -284,36 +415,106 @@ export class FolkNewsletterManager extends HTMLElement { }; 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(``); actions.push(``); + actions.push(``); } else if (c.status === 'running') { actions.push(``); } else if (c.status === 'paused') { actions.push(``); + } else if (c.status === 'scheduled' && this.isAdmin) { + actions.push(``); } return ` ${this.esc(c.name)} ${this.esc(c.subject || '—')} ${this.esc(c.status)} + ${listNames} ${c.sent ?? '—'} ${c.created_at ? new Date(c.created_at).toLocaleDateString() : '—'} - ${actions.join(' ')} + ${actions.join(' ')} `; }).join(''); return ` ${createBtn} - ${form} - + ${rows}
NameSubjectStatusSentCreatedActions
NameSubjectStatusListsSentCreatedActions
`; } + 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 ` + + `; + }).join(''); + + return ` +
+ +

${title}

+
+ ${this._error ? `
${this.esc(this._error)}
` : ''} +
+
+
+ + +
+
+ + +
+
+ +
+ ${listCheckboxes || 'No lists available'} +
+
+
+
+ + +
+
+ + +
+
+
+
+ + ${c.id ? `` : ''} +
+
+ +
+ + +
+ +
+
+ `; + } + // ── Event listeners ── private attachListeners() { @@ -336,7 +537,12 @@ export class FolkNewsletterManager extends HTMLElement { 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 => { btn.addEventListener('click', () => { 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; form?.addEventListener('submit', async (e) => { e.preventDefault(); @@ -367,6 +573,47 @@ export class FolkNewsletterManager extends HTMLElement { 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 { @@ -374,6 +621,10 @@ export class FolkNewsletterManager extends HTMLElement { d.textContent = s; return d.innerHTML; } + + private escAttr(s: string): string { + return s.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>'); + } } customElements.define('folk-newsletter-manager', FolkNewsletterManager); diff --git a/modules/rsocials/components/newsletter.css b/modules/rsocials/components/newsletter.css index c95317a..a45494d 100644 --- a/modules/rsocials/components/newsletter.css +++ b/modules/rsocials/components/newsletter.css @@ -60,5 +60,54 @@ .nl-form-row { display: flex; gap: .75rem; } .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 */ .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; } +} diff --git a/modules/rsocials/mod.ts b/modules/rsocials/mod.ts index b819fd8..40d0dcf 100644 --- a/modules/rsocials/mod.ts +++ b/modules/rsocials/mod.ts @@ -517,6 +517,51 @@ routes.put("/api/newsletter/campaigns/:id/status", async (c) => { 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 ── routes.get("/api/postiz/status", async (c) => {