ultimate-jekyll-manager
Version:
Ultimate Jekyll dependency manager
744 lines (638 loc) • 25.2 kB
JavaScript
/**
* Calendar Events (Campaign Editor)
* Campaign CRUD via FormManager + BEM API, modal management,
* type toggling (email/push), and results viewer.
*/
import { FormManager } from '__main_assets__/js/libs/form-manager.js';
import authorizedFetch from '__main_assets__/js/libs/authorized-fetch.js';
import { getPrerenderedIcon } from '__main_assets__/js/libs/prerendered-icons.js';
import webManager from 'web-manager';
import { DISPLAY_TYPES, formatDateUTC, formatTimeUTC, todayUTC } from './calendar-core.js';
import { renderEmailPreview, renderPushPreview } from './campaign-preview.js';
export default class CalendarEvents {
constructor(core) {
this.core = core;
this.editingCampaignId = null;
this.formManager = null;
this.$editorModal = null;
this.$resultsModal = null;
this._init();
}
// ============================================
// Initialization
// ============================================
_init() {
this._initEditorModal();
this._initResultsModal();
this._initForm();
this._initTypeToggle();
this._initRecurrenceToggle();
this._initSendNow();
this._initDeleteButton();
this._initCreateButton();
this._initPreview();
}
_initEditorModal() {
this.$editorModal = document.getElementById('campaign-editor-modal');
this.$editorModal.addEventListener('hidden.bs.modal', () => {
this.editingCampaignId = null;
this.formManager.reset();
this._toggleDeleteButton(false);
this._setType('email');
this._resetRecurrence();
document.getElementById('campaign-modal-title-text').textContent = 'Create Campaign';
});
}
_initResultsModal() {
this.$resultsModal = document.getElementById('campaign-results-modal');
document.getElementById('btn-retry-campaign').addEventListener('click', () => {
const campaign = this._retrySource;
if (!campaign) {
return;
}
this._getResultsModal().hide();
// Open editor with same settings but no ID (creates new)
this._populateFormFromCampaign(campaign, false);
document.getElementById('campaign-modal-title-text').textContent = 'Retry Campaign';
this._getEditorModal().show();
});
}
_getEditorModal() {
return bootstrap.Modal.getOrCreateInstance(this.$editorModal);
}
_getResultsModal() {
return bootstrap.Modal.getOrCreateInstance(this.$resultsModal);
}
_initForm() {
this.formManager = new FormManager('#campaign-editor-form', {
autoReady: true,
allowResubmit: true,
});
this.formManager.on('submit', async ({ data }) => {
const payload = this._buildPayload(data);
if (this.editingCampaignId) {
await this._updateCampaign(this.editingCampaignId, payload);
} else {
await this._createCampaign(payload);
}
this._getEditorModal().hide();
});
}
_initTypeToggle() {
const $emailFields = document.getElementById('email-fields');
const $pushFields = document.getElementById('push-fields');
const $subjectHint = document.getElementById('campaign-subject-hint');
document.querySelectorAll('input[name="campaign.type"]').forEach(($radio) => {
$radio.addEventListener('change', () => {
const isEmail = $radio.value === 'email';
$emailFields.classList.toggle('d-none', !isEmail);
$pushFields.classList.toggle('d-none', isEmail);
$subjectHint.textContent = isEmail ? '(email subject line)' : '(notification body text)';
});
});
}
_initCreateButton() {
const $btn = document.getElementById('btn-create-campaign');
if (!$btn) {
return;
}
$btn.addEventListener('click', () => {
this.openCreateModal(todayUTC(), '09:00');
});
}
_initPreview() {
const $previewTab = document.getElementById('tab-preview');
const $previewContainer = document.getElementById('campaign-preview-container');
// Capture form data BEFORE tab switch (Edit pane still visible), render AFTER
let pendingData = null;
$previewTab.addEventListener('show.bs.tab', () => {
pendingData = this.formManager.getData();
});
$previewTab.addEventListener('shown.bs.tab', () => {
this._renderPreview($previewContainer, pendingData);
pendingData = null;
});
// Handle click-to-test on push notification preview
$previewContainer.addEventListener('click', (e) => {
const $notification = e.target.closest('[data-click-action]');
if (!$notification) {
return;
}
const url = $notification.dataset.clickAction;
if (url && /^https?:\/\//i.test(url)) {
window.open(url, '_blank', 'noopener,noreferrer');
}
});
}
async _renderPreview($container, data) {
data = data || this.formManager.getData();
const type = (data.campaign && data.campaign.type) || 'email';
try {
if (type === 'email') {
$container.innerHTML = await renderEmailPreview(data);
} else {
$container.innerHTML = renderPushPreview(data);
}
} catch (error) {
$container.innerHTML = `<div class="text-danger small">Preview failed: ${webManager.utilities().escapeHTML(error.message)}</div>`;
}
}
_initRecurrenceToggle() {
const $checkbox = document.getElementById('campaign-recurring');
const $fields = document.getElementById('recurrence-fields');
const $patternSelect = document.getElementById('campaign-recurrence-pattern');
const $dayHint = document.getElementById('recurrence-day-hint');
const $monthRow = document.getElementById('recurrence-month-row');
$checkbox.addEventListener('change', () => {
$fields.classList.toggle('d-none', !$checkbox.checked);
});
$patternSelect.addEventListener('change', () => {
const pattern = $patternSelect.value;
// Update day hint based on pattern
if (pattern === 'weekly') {
$dayHint.textContent = '(of week, 0=Sun)';
} else {
$dayHint.textContent = '(of month)';
}
// Show month field only for yearly
$monthRow.classList.toggle('d-none', pattern !== 'yearly');
});
}
_initSendNow() {
document.getElementById('btn-send-now').addEventListener('click', () => {
const now = new Date();
document.getElementById('campaign-date').value = formatDateUTC(now);
document.getElementById('campaign-time').value = formatTimeUTC(now);
});
}
_initDeleteButton() {
document.getElementById('btn-delete-campaign').addEventListener('click', async () => {
if (!this.editingCampaignId) {
return;
}
if (!confirm('Delete this campaign? This cannot be undone.')) {
return;
}
try {
await this._deleteCampaign(this.editingCampaignId);
this._getEditorModal().hide();
} catch (error) {
this.formManager.showError(`Delete failed: ${error.message}`);
}
});
}
// ============================================
// Modal Operations
// ============================================
openCreateModal(date, time) {
this.editingCampaignId = null;
this.formManager.reset();
this._toggleDeleteButton(false);
this._setType('email');
document.getElementById('campaign-modal-title-text').textContent = 'Create Campaign';
// Pre-fill date and time
document.getElementById('campaign-date').value = date || '';
document.getElementById('campaign-time').value = time || '09:00';
this._getEditorModal().show();
}
openEditModal(campaignId) {
const campaign = this.core.getCampaign(campaignId);
if (!campaign) {
return;
}
const displayType = this.core.getCampaignDisplayType(campaign);
// Virtual recurring occurrences: open the recurring template editor
if (campaign._virtual) {
const template = this.core.getCampaign(campaign._recurringSourceId);
if (!template) {
return;
}
this.editingCampaignId = template.id;
this._toggleDeleteButton(true);
document.getElementById('campaign-modal-title-text').textContent = 'Edit Recurring Campaign';
this._populateFormFromCampaign(template, true);
this._getEditorModal().show();
return;
}
// History records and sent/failed: read-only results modal
if (displayType === DISPLAY_TYPES.RECURRING_HISTORY
|| campaign.status === 'sent'
|| campaign.status === 'failed') {
this._openResultsModal(campaign);
return;
}
// Recurring template: editable (changes apply to all future sends)
if (displayType === DISPLAY_TYPES.RECURRING_TEMPLATE) {
this.editingCampaignId = campaignId;
this._toggleDeleteButton(true);
document.getElementById('campaign-modal-title-text').textContent = 'Edit Recurring Campaign';
this._populateFormFromCampaign(campaign, true);
this._getEditorModal().show();
return;
}
// One-off pending: fully editable
this.editingCampaignId = campaignId;
this._toggleDeleteButton(true);
document.getElementById('campaign-modal-title-text').textContent = 'Edit Campaign';
this._populateFormFromCampaign(campaign, true);
this._getEditorModal().show();
}
_openResultsModal(campaign) {
this._retrySource = campaign;
const settings = campaign.settings || {};
document.getElementById('campaign-results-title-text').textContent =
`${settings.name || 'Campaign'} — ${campaign.status === 'sent' ? 'Sent' : 'Failed'}`;
// Show retry button for failed campaigns
document.getElementById('btn-retry-campaign').classList.toggle('d-none', campaign.status !== 'failed');
const $body = document.getElementById('campaign-results-body');
$body.innerHTML = this._renderResultsBody(campaign);
this._getResultsModal().show();
}
// ============================================
// Form Population
// ============================================
_populateFormFromCampaign(campaign, isEditing) {
const settings = campaign.settings || {};
const d = new Date(campaign.sendAt * 1000);
// Type
this._setType(campaign.type || 'email');
// Shared fields
document.getElementById('campaign-name').value = settings.name || '';
document.getElementById('campaign-subject').value = settings.subject || '';
document.getElementById('campaign-date').value = formatDateUTC(d);
document.getElementById('campaign-time').value = formatTimeUTC(d);
document.getElementById('campaign-discount-code').value = settings.discountCode || '';
document.getElementById('campaign-test').checked = !!settings.test;
// Targeting
document.getElementById('campaign-all').checked = !!settings.all;
document.getElementById('campaign-segments').value = (settings.segments || []).join(', ');
document.getElementById('campaign-exclude-segments').value = (settings.excludeSegments || []).join(', ');
// Email fields
document.getElementById('campaign-preheader').value = settings.preheader || '';
document.getElementById('campaign-content').value = settings.content || '';
document.getElementById('campaign-template').value = settings.template || 'default';
document.getElementById('campaign-sender').value = settings.sender || 'marketing';
// Push fields
document.getElementById('campaign-icon').value = settings.icon || '';
document.getElementById('campaign-click-action').value = settings.clickAction || '';
document.getElementById('campaign-push-tags').value =
(settings.filters && settings.filters.tags) ? settings.filters.tags.join(', ') : '';
document.getElementById('campaign-push-owner').value =
(settings.filters && settings.filters.owner) || '';
// Lists
document.getElementById('campaign-lists').value = (settings.lists || []).join(', ');
// Providers (checkboxes)
const providers = settings.providers || [];
document.getElementById('campaign-provider-sendgrid').checked = providers.includes('sendgrid');
document.getElementById('campaign-provider-beehiiv').checked = providers.includes('beehiiv');
// Advanced
document.getElementById('campaign-group').value = settings.group || '';
document.getElementById('campaign-categories').value = (settings.categories || []).join(', ');
// UTM
const utm = settings.utm || {};
const utmFields = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content'];
utmFields.forEach((key) => {
const $input = document.querySelector(`input[name="campaign.utm.${key}"]`);
if ($input) {
$input.value = utm[key] || '';
}
});
// Recurrence
const recurrence = campaign.recurrence;
const $recurringCheckbox = document.getElementById('campaign-recurring');
const $recurrenceFields = document.getElementById('recurrence-fields');
if (recurrence) {
$recurringCheckbox.checked = true;
$recurrenceFields.classList.remove('d-none');
document.getElementById('campaign-recurrence-pattern').value = recurrence.pattern || 'monthly';
document.getElementById('campaign-recurrence-hour').value = recurrence.hour || 0;
document.getElementById('campaign-recurrence-day').value = recurrence.day || 1;
document.getElementById('campaign-recurrence-month').value = recurrence.month || 1;
// Show month row if yearly
document.getElementById('recurrence-month-row').classList.toggle('d-none', recurrence.pattern !== 'yearly');
// Update day hint
const $dayHint = document.getElementById('recurrence-day-hint');
$dayHint.textContent = recurrence.pattern === 'weekly' ? '(of week, 0=Sun)' : '(of month)';
} else {
$recurringCheckbox.checked = false;
$recurrenceFields.classList.add('d-none');
}
}
_setType(type) {
const $radio = document.getElementById(`campaign-type-${type}`);
if ($radio) {
$radio.checked = true;
$radio.dispatchEvent(new Event('change'));
}
}
// ============================================
// Payload Building
// ============================================
_buildPayload(formData) {
const c = formData.campaign || {};
const type = c.type || 'email';
// Convert date + time to sendAt
const sendAt = this._dateTimeToISO(c.date, c.time);
const payload = {
type,
name: (c.name || '').trim(),
subject: (c.subject || '').trim(),
sendAt,
};
// Config
if (c.discountCode) {
payload.discountCode = c.discountCode.trim();
}
if (c.test) {
payload.test = true;
}
// Providers (checkboxes → array)
const providersCb = c.providers || {};
const providers = Object.keys(providersCb).filter((p) => providersCb[p]);
if (providers.length) {
payload.providers = providers;
}
// Targeting
if (c.all) {
payload.all = true;
}
const lists = this._csvToArray(c.lists);
if (lists.length) {
payload.lists = lists;
}
const segments = this._csvToArray(c.segments);
if (segments.length) {
payload.segments = segments;
}
const excludeSegments = this._csvToArray(c.excludeSegments);
if (excludeSegments.length) {
payload.excludeSegments = excludeSegments;
}
// Email-specific
if (type === 'email') {
if (c.preheader) {
payload.preheader = c.preheader.trim();
}
if (c.content) {
payload.content = c.content;
}
if (c.template && c.template !== 'default') {
payload.template = c.template;
}
if (c.sender) {
payload.sender = c.sender;
}
}
// Push-specific
if (type === 'push') {
if (c.icon) {
payload.icon = c.icon.trim();
}
if (c.clickAction) {
payload.clickAction = c.clickAction.trim();
}
const filters = c.filters || {};
const tags = this._csvToArray(filters.tags);
if (tags.length || filters.owner) {
payload.filters = {};
if (tags.length) {
payload.filters.tags = tags;
}
if (filters.owner) {
payload.filters.owner = filters.owner.trim();
}
}
}
// Advanced
if (c.group) {
payload.group = c.group.trim();
}
const categories = this._csvToArray(c.categories);
if (categories.length) {
payload.categories = categories;
}
// UTM
const utm = c.utm || {};
const utmClean = {};
let hasUtm = false;
Object.entries(utm).forEach(([key, val]) => {
if (val && val.trim()) {
utmClean[key] = val.trim();
hasUtm = true;
}
});
if (hasUtm) {
payload.utm = utmClean;
}
// Recurrence
if (c.recurring) {
const rec = c.recurrence || {};
payload.recurrence = {
pattern: rec.pattern || 'monthly',
hour: parseInt(rec.hour, 10) || 0,
day: parseInt(rec.day, 10) || 1,
};
if (rec.pattern === 'yearly') {
payload.recurrence.month = parseInt(rec.month, 10) || 1;
}
}
return payload;
}
_dateTimeToISO(date, time) {
if (!date || !time) {
return 'now';
}
return `${date}T${time}:00Z`;
}
_csvToArray(str) {
if (!str) {
return [];
}
// Handle both string and array inputs
if (Array.isArray(str)) {
return str;
}
return str.split(',').map((s) => s.trim()).filter(Boolean);
}
// ============================================
// BEM API Calls
// ============================================
async _createCampaign(payload) {
const url = `${webManager.getApiUrl()}/backend-manager/marketing/campaign`;
const response = await authorizedFetch(url, {
method: 'POST',
timeout: 60000,
response: 'json',
tries: 1,
log: true,
body: payload,
});
this.formManager.showSuccess('Campaign created');
return response;
}
async _updateCampaign(id, payload) {
payload.id = id;
const url = `${webManager.getApiUrl()}/backend-manager/marketing/campaign`;
const response = await authorizedFetch(url, {
method: 'PUT',
timeout: 60000,
response: 'json',
tries: 1,
log: true,
body: payload,
});
this.formManager.showSuccess('Campaign updated');
return response;
}
async _deleteCampaign(id) {
const url = `${webManager.getApiUrl()}/backend-manager/marketing/campaign`;
const response = await authorizedFetch(url, {
method: 'DELETE',
timeout: 60000,
response: 'json',
tries: 1,
log: true,
body: { id },
});
return response;
}
/**
* Reschedule a one-off campaign (drag-and-drop)
*/
async rescheduleCampaign(id, newSendAt) {
const url = `${webManager.getApiUrl()}/backend-manager/marketing/campaign`;
return authorizedFetch(url, {
method: 'PUT',
timeout: 60000,
response: 'json',
tries: 1,
log: true,
body: { id, sendAt: newSendAt },
});
}
/**
* Reschedule a recurring campaign by updating its sendAt (seed time).
* All virtual occurrences recalculate from the new seed.
* Also updates recurrence metadata (day/hour) for the backend cron.
* @param {string} templateId - The recurring template doc ID
* @param {object} virtualEvent - The virtual event that was dragged
* @param {number} newSendAtUNIX - New sendAt as unix timestamp
*/
async rescheduleRecurring(templateId, virtualEvent, newSendAtUNIX) {
const template = this.core.getCampaign(templateId);
if (!template || !template.recurrence) {
return;
}
const recurrence = { ...template.recurrence };
const d = new Date(newSendAtUNIX * 1000);
// Update recurrence metadata for the backend cron
recurrence.hour = d.getUTCHours();
if (recurrence.pattern === 'weekly') {
recurrence.day = d.getUTCDay();
} else if (recurrence.pattern === 'monthly' || recurrence.pattern === 'quarterly') {
recurrence.day = d.getUTCDate();
} else if (recurrence.pattern === 'yearly') {
recurrence.month = d.getUTCMonth() + 1;
recurrence.day = d.getUTCDate();
}
// Optimistic update: move the seed sendAt, re-render immediately
const rollback = this.core.optimisticUpdateSendAt(templateId, newSendAtUNIX);
const url = `${webManager.getApiUrl()}/backend-manager/marketing/campaign`;
return authorizedFetch(url, {
method: 'PUT',
timeout: 60000,
response: 'json',
tries: 1,
log: true,
body: {
id: templateId,
sendAt: d.toISOString(),
recurrence,
},
}).catch((err) => {
console.error('Failed to reschedule recurring campaign:', err);
if (rollback) {
rollback();
}
});
}
// ============================================
// Results Rendering
// ============================================
_renderResultsBody(campaign) {
const settings = campaign.settings || {};
const results = campaign.results || {};
const d = new Date(campaign.sendAt * 1000);
let html = '';
// Status badge
const statusBadge = campaign.status === 'sent'
? `<span class="badge bg-success">${getPrerenderedIcon('circle-check', 'fa-xs me-1')} Sent</span>`
: `<span class="badge bg-danger">${getPrerenderedIcon('triangle-exclamation', 'fa-xs me-1')} Failed</span>`;
// Overview
html += '<div class="mb-4">';
html += `<div class="d-flex align-items-center gap-2 mb-2">`;
html += statusBadge;
html += `<span class="badge bg-${campaign.type === 'email' ? 'primary' : 'success'}">${campaign.type === 'email' ? 'Email' : 'Push'}</span>`;
html += `</div>`;
html += `<table class="table table-sm table-borderless mb-0">`;
html += `<tr><td class="text-muted" style="width:120px">Name</td><td>${webManager.utilities().escapeHTML(settings.name || '')}</td></tr>`;
html += `<tr><td class="text-muted">Subject</td><td>${webManager.utilities().escapeHTML(settings.subject || '')}</td></tr>`;
html += `<tr><td class="text-muted">Sent At</td><td>${formatDateUTC(d)} ${formatTimeUTC(d)} UTC</td></tr>`;
if (campaign.type === 'email') {
if (settings.preheader) {
html += `<tr><td class="text-muted">Preheader</td><td>${webManager.utilities().escapeHTML(settings.preheader)}</td></tr>`;
}
if (settings.sender) {
html += `<tr><td class="text-muted">Sender</td><td>${webManager.utilities().escapeHTML(settings.sender)}</td></tr>`;
}
if (settings.template) {
html += `<tr><td class="text-muted">Template</td><td>${webManager.utilities().escapeHTML(settings.template)}</td></tr>`;
}
}
if (campaign.type === 'push') {
if (settings.icon) {
const iconUrl = /^https?:\/\//i.test(settings.icon) ? webManager.utilities().escapeHTML(settings.icon) : '#';
html += `<tr><td class="text-muted">Icon</td><td><a href="${iconUrl}" target="_blank" rel="noopener">${webManager.utilities().escapeHTML(settings.icon)}</a></td></tr>`;
}
if (settings.clickAction) {
const clickUrl = /^https?:\/\//i.test(settings.clickAction) ? webManager.utilities().escapeHTML(settings.clickAction) : '#';
html += `<tr><td class="text-muted">Click URL</td><td><a href="${clickUrl}" target="_blank" rel="noopener">${webManager.utilities().escapeHTML(settings.clickAction)}</a></td></tr>`;
}
}
if (campaign.recurringId) {
html += `<tr><td class="text-muted">Recurring</td><td>${webManager.utilities().escapeHTML(campaign.recurringId)}</td></tr>`;
}
html += `</table>`;
html += '</div>';
// Content (email only)
if (campaign.type === 'email' && settings.content) {
html += '<div class="mb-4">';
html += '<h6>Content</h6>';
html += `<pre class="bg-body-tertiary p-3 rounded small" style="white-space:pre-wrap;max-height:200px;overflow-y:auto">${webManager.utilities().escapeHTML(settings.content)}</pre>`;
html += '</div>';
}
// Results
if (Object.keys(results).length > 0) {
html += '<div class="mb-3">';
html += '<h6>Results</h6>';
html += `<pre class="bg-body-tertiary p-3 rounded small" style="white-space:pre-wrap;max-height:300px;overflow-y:auto">${webManager.utilities().escapeHTML(JSON.stringify(results, null, 2))}</pre>`;
html += '</div>';
}
return html;
}
// ============================================
// UI Helpers
// ============================================
_toggleDeleteButton(show) {
document.getElementById('btn-delete-campaign').classList.toggle('d-none', !show);
}
_resetRecurrence() {
document.getElementById('campaign-recurring').checked = false;
document.getElementById('recurrence-fields').classList.add('d-none');
document.getElementById('campaign-recurrence-pattern').value = 'monthly';
document.getElementById('campaign-recurrence-hour').value = '14';
document.getElementById('campaign-recurrence-day').value = '1';
document.getElementById('campaign-recurrence-month').value = '1';
document.getElementById('recurrence-month-row').classList.add('d-none');
}
}