ultimate-jekyll-manager 1.0.2 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/CLAUDE.md +64 -1
  3. package/TODO.md +13 -0
  4. package/dist/assets/css/pages/admin/calendar/index.scss +212 -18
  5. package/dist/assets/js/pages/admin/calendar/calendar-core.js +535 -95
  6. package/dist/assets/js/pages/admin/calendar/calendar-events.js +631 -124
  7. package/dist/assets/js/pages/admin/calendar/calendar-renderer.js +238 -69
  8. package/dist/assets/js/pages/admin/calendar/campaign-preview.js +100 -0
  9. package/dist/assets/js/pages/admin/calendar/index.js +3 -16
  10. package/dist/assets/js/pages/contact/index.js +5 -1
  11. package/dist/defaults/dist/_includes/admin/sections/sidebar.json +0 -34
  12. package/dist/defaults/dist/_includes/admin/sections/topbar.json +0 -34
  13. package/dist/defaults/dist/_includes/themes/classy/backend/sections/topbar.html +1 -72
  14. package/dist/defaults/dist/_includes/themes/classy/frontend/sections/nav.html +7 -140
  15. package/dist/defaults/dist/_includes/themes/classy/global/sections/account.html +72 -0
  16. package/dist/defaults/dist/_layouts/blueprint/admin/calendar/index.html +442 -159
  17. package/dist/defaults/src/_includes/backend/sections/topbar.json +0 -34
  18. package/dist/defaults/src/_includes/frontend/sections/nav.json +0 -34
  19. package/dist/defaults/src/_includes/global/sections/account.json +36 -0
  20. package/package.json +2 -1
  21. package/dist/assets/js/pages/admin/notifications/index.js +0 -53
  22. package/dist/assets/js/pages/admin/notifications/new/index.js +0 -492
  23. package/dist/defaults/dist/_layouts/blueprint/admin/newsletters/index.html +0 -59
  24. package/dist/defaults/dist/_layouts/blueprint/admin/newsletters/new.html +0 -46
  25. package/dist/defaults/dist/_layouts/blueprint/admin/notifications/index.html +0 -103
  26. package/dist/defaults/dist/_layouts/blueprint/admin/notifications/new.html +0 -399
  27. package/dist/defaults/dist/pages/admin/newsletters/index.html +0 -7
  28. package/dist/defaults/dist/pages/admin/newsletters/new.html +0 -7
  29. package/dist/defaults/dist/pages/admin/notifications/index.html +0 -7
  30. package/dist/defaults/dist/pages/admin/notifications/new.html +0 -7
@@ -1,10 +1,13 @@
1
1
  /**
2
2
  * Calendar Renderer
3
3
  * Renders the toolbar and calendar grid for all 4 view modes.
4
+ * Campaigns are color-coded by type (email=blue, push=green) and
5
+ * styled by status (pending=normal, sent=faded+checkmark, failed=red).
4
6
  */
5
7
 
6
8
  import { getPrerenderedIcon } from '__main_assets__/js/libs/prerendered-icons.js';
7
- import { VIEW_MODES, DAY_ABBREVS, MONTH_NAMES } from './calendar-core.js';
9
+ import { escapeHtml } from '__main_assets__/js/libs/admin-helpers.js';
10
+ import { VIEW_MODES, DAY_ABBREVS, MONTH_NAMES, TYPE_COLORS, STATUS_STYLES, DISPLAY_TYPES } from './calendar-core.js';
8
11
 
9
12
  export default class CalendarRenderer {
10
13
  constructor(core) {
@@ -80,6 +83,7 @@ export default class CalendarRenderer {
80
83
  week: 'calendar-week',
81
84
  month: 'calendar',
82
85
  year: 'grid-2',
86
+ list: 'table-list',
83
87
  };
84
88
  return getPrerenderedIcon(icons[mode], 'fa-sm');
85
89
  }
@@ -101,6 +105,9 @@ export default class CalendarRenderer {
101
105
  case 'year':
102
106
  this._renderYearView();
103
107
  break;
108
+ case 'list':
109
+ this._renderListView();
110
+ break;
104
111
  }
105
112
  }
106
113
 
@@ -125,7 +132,7 @@ export default class CalendarRenderer {
125
132
  cells.forEach((cell) => {
126
133
  const dateStr = core.formatDate(cell.date);
127
134
  const isToday = core.isToday(cell.date);
128
- const events = core.getEventsForDate(dateStr);
135
+ const campaigns = core.getCampaignsForDate(dateStr);
129
136
 
130
137
  let classes = 'calendar-cell';
131
138
  if (isToday) {
@@ -140,12 +147,12 @@ export default class CalendarRenderer {
140
147
  html += '<div class="calendar-cell-events">';
141
148
 
142
149
  const maxVisible = 3;
143
- events.slice(0, maxVisible).forEach((evt) => {
144
- html += this._renderEventPill(evt);
150
+ campaigns.slice(0, maxVisible).forEach((campaign) => {
151
+ html += this._renderEventPill(campaign);
145
152
  });
146
153
 
147
- if (events.length > maxVisible) {
148
- html += `<div class="calendar-cell-more" data-date="${dateStr}">+${events.length - maxVisible} more</div>`;
154
+ if (campaigns.length > maxVisible) {
155
+ html += `<div class="calendar-cell-more" data-date="${dateStr}">+${campaigns.length - maxVisible} more</div>`;
149
156
  }
150
157
 
151
158
  html += '</div></div>';
@@ -204,7 +211,7 @@ export default class CalendarRenderer {
204
211
  // Day columns
205
212
  weekDates.forEach((date) => {
206
213
  const dateStr = core.formatDate(date);
207
- const events = core.getEventsForDate(dateStr);
214
+ const campaigns = core.getCampaignsForDate(dateStr);
208
215
 
209
216
  html += `<div class="calendar-week-day-col" data-date="${dateStr}">`;
210
217
  hours.forEach((hour) => {
@@ -212,9 +219,9 @@ export default class CalendarRenderer {
212
219
  });
213
220
 
214
221
  // Positioned events with overlap layout
215
- const layout = this._calculateOverlapLayout(events);
216
- events.forEach((evt) => {
217
- html += this._renderTimeEvent(evt, layout.get(evt.id));
222
+ const layout = this._calculateOverlapLayout(campaigns);
223
+ campaigns.forEach((campaign) => {
224
+ html += this._renderTimeEvent(campaign, layout.get(campaign.id));
218
225
  });
219
226
 
220
227
  html += '</div>';
@@ -235,7 +242,7 @@ export default class CalendarRenderer {
235
242
  const core = this.core;
236
243
  const date = core.currentDate;
237
244
  const dateStr = core.formatDate(date);
238
- const events = core.getEventsForDate(dateStr);
245
+ const campaigns = core.getCampaignsForDate(dateStr);
239
246
  const hours = this._getHours();
240
247
 
241
248
  let html = '';
@@ -257,9 +264,9 @@ export default class CalendarRenderer {
257
264
  });
258
265
 
259
266
  // Positioned events with overlap layout
260
- const layout = this._calculateOverlapLayout(events);
261
- events.forEach((evt) => {
262
- html += this._renderTimeEvent(evt, layout.get(evt.id));
267
+ const layout = this._calculateOverlapLayout(campaigns);
268
+ campaigns.forEach((campaign) => {
269
+ html += this._renderTimeEvent(campaign, layout.get(campaign.id));
263
270
  });
264
271
 
265
272
  html += '</div></div>';
@@ -303,13 +310,13 @@ export default class CalendarRenderer {
303
310
  const date = new Date(year, month, d);
304
311
  const dateStr = core.formatDate(date);
305
312
  const isToday = core.isToday(date);
306
- const hasEvents = core.getEventsForDate(dateStr).length > 0;
313
+ const hasCampaigns = core.getCampaignsForDate(dateStr).length > 0;
307
314
 
308
315
  let classes = 'calendar-mini-day';
309
316
  if (isToday) {
310
317
  classes += ' calendar-cell--today';
311
318
  }
312
- if (hasEvents) {
319
+ if (hasCampaigns) {
313
320
  classes += ' has-events';
314
321
  }
315
322
 
@@ -334,28 +341,156 @@ export default class CalendarRenderer {
334
341
  }
335
342
 
336
343
  // ============================================
337
- // Event Rendering Helpers
344
+ // List View
338
345
  // ============================================
339
- _renderEventPill(evt) {
340
- const timeStr = this._formatTime(evt.time);
346
+ _renderListView() {
347
+ const core = this.core;
348
+ const campaigns = core.getAllCampaignsSorted();
349
+
350
+ if (campaigns.length === 0) {
351
+ this.$grid.innerHTML = `
352
+ <div class="calendar-list-empty">
353
+ <div class="text-center">
354
+ <p>No campaigns in this date range</p>
355
+ </div>
356
+ </div>
357
+ `;
358
+ return;
359
+ }
360
+
361
+ let html = '<div class="calendar-list"><table>';
362
+
363
+ let currentDateStr = null;
364
+
365
+ campaigns.forEach((campaign) => {
366
+ const dateStr = core._campaignDate(campaign);
367
+
368
+ // Date group header
369
+ if (dateStr !== currentDateStr) {
370
+ currentDateStr = dateStr;
371
+ const d = new Date(campaign.sendAt * 1000);
372
+ const dayName = DAY_ABBREVS[d.getDay()];
373
+ const monthName = MONTH_NAMES[d.getMonth()].slice(0, 3);
374
+ const isToday = core.isToday(d);
375
+ const todayBadge = isToday ? ' <span class="badge bg-danger ms-2">Today</span>' : '';
376
+
377
+ html += `<tr class="calendar-list-date-header"><td colspan="4">${dayName}, ${monthName} ${d.getDate()}, ${d.getFullYear()}${todayBadge}</td></tr>`;
378
+ }
379
+
380
+ // Campaign row
381
+ const time = this._formatTime(core.campaignTime(campaign));
382
+ const name = (campaign.settings && campaign.settings.name) || 'Untitled';
383
+ const color = core.campaignColor(campaign);
384
+ const statusStyle = core.campaignStatusStyle(campaign);
385
+ const isRecurring = campaign.recurrence || campaign.recurringId || campaign._virtual;
386
+
387
+ const typeBadge = campaign.type === 'email'
388
+ ? `<span class="badge" style="background-color: ${TYPE_COLORS.email}">${getPrerenderedIcon('envelope', 'fa-xs me-1')} Email</span>`
389
+ : `<span class="badge" style="background-color: ${TYPE_COLORS.push}">${getPrerenderedIcon('bell', 'fa-xs me-1')} Push</span>`;
390
+
391
+ let statusBadge = '';
392
+ if (campaign.status === 'sent') {
393
+ statusBadge = `<span class="badge bg-success">${getPrerenderedIcon('circle-check', 'fa-xs me-1')} Sent</span>`;
394
+ } else if (campaign.status === 'failed') {
395
+ statusBadge = `<span class="badge bg-danger">${getPrerenderedIcon('triangle-exclamation', 'fa-xs me-1')} Failed</span>`;
396
+ } else {
397
+ statusBadge = '<span class="badge bg-secondary">Pending</span>';
398
+ }
399
+
400
+ const recurringIcon = isRecurring ? getPrerenderedIcon('repeat', 'fa-xs me-1 text-muted') : '';
401
+
402
+ html += `
403
+ <tr class="calendar-list-row" data-campaign-id="${campaign.id}" style="opacity: ${statusStyle.opacity}">
404
+ <td style="width: 60px">${time}</td>
405
+ <td>${recurringIcon}${escapeHtml(name)}</td>
406
+ <td style="width: 80px">${typeBadge}</td>
407
+ <td style="width: 80px">${statusBadge}</td>
408
+ </tr>
409
+ `;
410
+ });
411
+
412
+ html += '</table></div>';
413
+
414
+ this.$grid.innerHTML = html;
415
+
416
+ // Bind row clicks
417
+ this.$grid.querySelectorAll('.calendar-list-row').forEach(($row) => {
418
+ $row.addEventListener('click', (e) => {
419
+ e.stopPropagation();
420
+ const id = $row.dataset.campaignId;
421
+ core.eventsManager.openEditModal(id);
422
+ });
423
+ });
424
+ }
425
+
426
+ // ============================================
427
+ // Campaign Rendering Helpers
428
+ // ============================================
429
+ _renderEventPill(campaign) {
430
+ const core = this.core;
431
+ const timeStr = this._formatTime(core.campaignTime(campaign));
432
+ const color = core.campaignColor(campaign);
433
+ const statusStyle = core.campaignStatusStyle(campaign);
434
+ const name = (campaign.settings && campaign.settings.name) || 'Untitled';
435
+ const isEditable = core.isEditable(campaign);
436
+ const isRecurring = campaign.recurrence || campaign.recurringId || campaign._virtual;
437
+ const statusIcon = statusStyle.icon
438
+ ? getPrerenderedIcon(statusStyle.icon, 'fa-xs')
439
+ : '';
440
+ const typeIcon = campaign.type === 'email'
441
+ ? getPrerenderedIcon('envelope', 'fa-xs')
442
+ : getPrerenderedIcon('bell', 'fa-xs');
443
+ const recurringIcon = isRecurring
444
+ ? getPrerenderedIcon('repeat', 'fa-xs')
445
+ : '';
446
+
447
+ let pillClass = 'calendar-event';
448
+ if (campaign.status === 'failed') {
449
+ pillClass += ' calendar-event--failed';
450
+ }
451
+ if (campaign.status === 'sent') {
452
+ pillClass += ' calendar-event--sent';
453
+ }
454
+ if (campaign._virtual) {
455
+ pillClass += ' calendar-event--virtual';
456
+ }
457
+ if (isRecurring && !campaign._virtual) {
458
+ pillClass += ' calendar-event--recurring';
459
+ }
341
460
 
342
461
  return `
343
- <div class="calendar-event"
344
- data-event-id="${evt.id}"
345
- draggable="true"
346
- style="background-color: ${evt.color};"
347
- title="${evt.title}">
462
+ <div class="${pillClass}"
463
+ data-campaign-id="${campaign.id}"
464
+ ${isEditable && !campaign._virtual ? 'draggable="true"' : ''}
465
+ style="background-color: ${color}; opacity: ${statusStyle.opacity};"
466
+ title="${escapeHtml(name)}${isRecurring ? ' (recurring)' : ''}">
467
+ ${statusIcon}
468
+ ${recurringIcon}
348
469
  <span class="calendar-event-time">${timeStr}</span>
349
- <span class="calendar-event-title">${this._escapeHtml(evt.title)}</span>
470
+ <span class="calendar-event-type-icon">${typeIcon}</span>
471
+ <span class="calendar-event-title">${escapeHtml(name)}</span>
350
472
  </div>
351
473
  `;
352
474
  }
353
475
 
354
- _renderTimeEvent(evt, layout) {
355
- const [hours, minutes] = evt.time.split(':').map(Number);
476
+ _renderTimeEvent(campaign, layout) {
477
+ const core = this.core;
478
+ const time = core.campaignTime(campaign);
479
+ const [hours, minutes] = time.split(':').map(Number);
356
480
  const topPx = (hours * 60 + minutes);
357
- const heightPx = Math.max(evt.duration || 60, 15);
358
- const timeStr = this._formatTime(evt.time);
481
+ const heightPx = Math.max(core.campaignDuration(), 15);
482
+ const timeStr = this._formatTime(time);
483
+ const color = core.campaignColor(campaign);
484
+ const statusStyle = core.campaignStatusStyle(campaign);
485
+ const name = (campaign.settings && campaign.settings.name) || 'Untitled';
486
+ const isEditable = core.isEditable(campaign);
487
+ const isRecurring = campaign.recurrence || campaign.recurringId || campaign._virtual;
488
+ const statusIcon = statusStyle.icon
489
+ ? getPrerenderedIcon(statusStyle.icon, 'fa-xs me-1')
490
+ : '';
491
+ const recurringIcon = isRecurring
492
+ ? getPrerenderedIcon('repeat', 'fa-xs me-1')
493
+ : '';
359
494
 
360
495
  // Layout: column position and total columns for side-by-side overlap
361
496
  const col = layout ? layout.col : 0;
@@ -367,34 +502,52 @@ export default class CalendarRenderer {
367
502
  ? `left: ${leftPct}%; width: calc(${widthPct}% - 2px);`
368
503
  : '';
369
504
 
505
+ let eventClass = 'calendar-week-event';
506
+ if (campaign.status === 'failed') {
507
+ eventClass += ' calendar-event--failed';
508
+ }
509
+ if (campaign.status === 'sent') {
510
+ eventClass += ' calendar-event--sent';
511
+ }
512
+ if (campaign._virtual) {
513
+ eventClass += ' calendar-event--virtual';
514
+ }
515
+ if (isRecurring && !campaign._virtual) {
516
+ eventClass += ' calendar-event--recurring';
517
+ }
518
+
370
519
  return `
371
- <div class="calendar-week-event"
372
- data-event-id="${evt.id}"
373
- draggable="true"
374
- style="background-color: ${evt.color}; top: ${topPx}px; height: ${heightPx}px; ${sizeStyle}"
375
- title="${evt.title}">
376
- <strong>${timeStr}</strong> ${this._escapeHtml(evt.title)}
520
+ <div class="${eventClass}"
521
+ data-campaign-id="${campaign.id}"
522
+ ${isEditable && !campaign._virtual ? 'draggable="true"' : ''}
523
+ style="background-color: ${color}; opacity: ${statusStyle.opacity}; top: ${topPx}px; height: ${heightPx}px; ${sizeStyle}"
524
+ title="${escapeHtml(name)}${isRecurring ? ' (recurring)' : ''}">
525
+ ${statusIcon}${recurringIcon}<strong>${timeStr}</strong> ${escapeHtml(name)}
377
526
  </div>
378
527
  `;
379
528
  }
380
529
 
381
530
  /**
382
- * Calculate side-by-side layout for overlapping events.
383
- * Returns a Map of eventId → { col, totalCols }
531
+ * Calculate side-by-side layout for overlapping campaigns.
532
+ * Returns a Map of campaignId → { col, totalCols }
384
533
  */
385
- _calculateOverlapLayout(events) {
534
+ _calculateOverlapLayout(campaigns) {
386
535
  const layout = new Map();
387
536
 
388
- if (events.length === 0) {
537
+ if (campaigns.length === 0) {
389
538
  return layout;
390
539
  }
391
540
 
541
+ const core = this.core;
542
+ const duration = core.campaignDuration();
543
+
392
544
  // Convert to intervals
393
- const intervals = events.map((evt) => {
394
- const [h, m] = evt.time.split(':').map(Number);
545
+ const intervals = campaigns.map((c) => {
546
+ const time = core.campaignTime(c);
547
+ const [h, m] = time.split(':').map(Number);
395
548
  const start = h * 60 + m;
396
- const end = start + (evt.duration || 60);
397
- return { id: evt.id, start, end };
549
+ const end = start + duration;
550
+ return { id: c.id, start, end };
398
551
  }).sort((a, b) => a.start - b.start || a.end - b.end);
399
552
 
400
553
  // Group overlapping events into clusters
@@ -417,7 +570,6 @@ export default class CalendarRenderer {
417
570
  const columns = [];
418
571
 
419
572
  cluster.forEach((interval) => {
420
- // Find the first column where this event fits (no overlap)
421
573
  let placed = false;
422
574
  for (let c = 0; c < columns.length; c++) {
423
575
  const lastInCol = columns[c];
@@ -487,13 +639,12 @@ export default class CalendarRenderer {
487
639
  _bindCellClicks() {
488
640
  const core = this.core;
489
641
 
490
- // Month view: single click → day view, double click → create event
491
- // Debounce single click to avoid firing when double clicking
642
+ // Month view: single click → day view, double click → create campaign
492
643
  let clickTimer = null;
493
644
 
494
645
  this.$grid.querySelectorAll('.calendar-cell').forEach(($cell) => {
495
646
  $cell.addEventListener('click', (e) => {
496
- if (e.target.closest('.calendar-event, .calendar-week-event')) {
647
+ if (e.target.closest('[data-campaign-id]')) {
497
648
  return;
498
649
  }
499
650
 
@@ -511,7 +662,7 @@ export default class CalendarRenderer {
511
662
  });
512
663
 
513
664
  $cell.addEventListener('dblclick', (e) => {
514
- if (e.target.closest('.calendar-event, .calendar-week-event')) {
665
+ if (e.target.closest('[data-campaign-id]')) {
515
666
  return;
516
667
  }
517
668
 
@@ -526,10 +677,10 @@ export default class CalendarRenderer {
526
677
  });
527
678
  });
528
679
 
529
- // Time slots (week/day views): double click only to create event
680
+ // Time slots (week/day views): double click only to create campaign
530
681
  this.$grid.querySelectorAll('.calendar-week-time-slot').forEach(($slot) => {
531
682
  $slot.addEventListener('dblclick', (e) => {
532
- if (e.target.closest('.calendar-event, .calendar-week-event')) {
683
+ if (e.target.closest('[data-campaign-id]')) {
533
684
  return;
534
685
  }
535
686
 
@@ -559,24 +710,24 @@ export default class CalendarRenderer {
559
710
  }
560
711
 
561
712
  _bindEventPillClicks() {
562
- this.$grid.querySelectorAll('[data-event-id]').forEach(($pill) => {
713
+ this.$grid.querySelectorAll('[data-campaign-id]').forEach(($pill) => {
563
714
  $pill.addEventListener('click', (e) => {
564
715
  e.stopPropagation();
565
- const id = $pill.dataset.eventId;
716
+ const id = $pill.dataset.campaignId;
566
717
  this.core.eventsManager.openEditModal(id);
567
718
  });
568
719
  });
569
720
  }
570
721
 
571
722
  _bindDragAndDrop() {
572
- // Drag start on event pills
573
- this.$grid.querySelectorAll('[data-event-id][draggable]').forEach(($pill) => {
723
+ const core = this.core;
724
+
725
+ // Drag start on pending campaign pills only
726
+ this.$grid.querySelectorAll('[data-campaign-id][draggable]').forEach(($pill) => {
574
727
  $pill.addEventListener('dragstart', (e) => {
575
- e.dataTransfer.setData('text/plain', $pill.dataset.eventId);
728
+ e.dataTransfer.setData('text/plain', $pill.dataset.campaignId);
576
729
  e.dataTransfer.effectAllowed = 'move';
577
730
  $pill.classList.add('calendar-event--dragging');
578
- // Disable pointer-events on all events so drops pass through to time slots
579
- // Use requestAnimationFrame so the drag has started before we disable pointer-events
580
731
  requestAnimationFrame(() => {
581
732
  this.$grid.classList.add('calendar-grid--dragging');
582
733
  });
@@ -585,7 +736,6 @@ export default class CalendarRenderer {
585
736
  $pill.addEventListener('dragend', () => {
586
737
  $pill.classList.remove('calendar-event--dragging');
587
738
  this.$grid.classList.remove('calendar-grid--dragging');
588
- // Clear all drag-over states
589
739
  this.$grid.querySelectorAll('.calendar-cell--drag-over').forEach(($el) => {
590
740
  $el.classList.remove('calendar-cell--drag-over');
591
741
  });
@@ -609,20 +759,44 @@ export default class CalendarRenderer {
609
759
  e.preventDefault();
610
760
  $cell.classList.remove('calendar-cell--drag-over');
611
761
 
612
- const eventId = e.dataTransfer.getData('text/plain');
762
+ const campaignId = e.dataTransfer.getData('text/plain');
613
763
  const newDate = $cell.dataset.date;
614
- if (!eventId || !newDate) {
764
+ if (!campaignId || !newDate) {
765
+ return;
766
+ }
767
+
768
+ // Only allow rescheduling editable campaigns (pending one-offs and recurring templates)
769
+ const campaign = core.getCampaign(campaignId);
770
+ if (!campaign || !core.isEditable(campaign)) {
615
771
  return;
616
772
  }
617
773
 
618
- const changes = { date: newDate };
774
+ // Build new sendAt from drop target
775
+ const parts = newDate.split('-');
776
+ const d = new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]));
619
777
 
620
- // If dropping on a time slot, update time too
778
+ // If dropping on a time slot, use that hour
621
779
  if ($cell.dataset.hour) {
622
- changes.time = String($cell.dataset.hour).padStart(2, '0') + ':00';
780
+ d.setHours(parseInt($cell.dataset.hour), 0, 0, 0);
781
+ } else {
782
+ // Keep original time
783
+ const originalDate = new Date(campaign.sendAt * 1000);
784
+ d.setHours(originalDate.getHours(), originalDate.getMinutes(), 0, 0);
623
785
  }
624
786
 
625
- this.core.updateEvent(eventId, changes);
787
+ const newSendAtUNIX = Math.floor(d.getTime() / 1000);
788
+ const newSendAtISO = d.toISOString();
789
+
790
+ // Optimistic update: render immediately, rollback on failure
791
+ const rollback = core.optimisticUpdateSendAt(campaignId, newSendAtUNIX);
792
+
793
+ core.eventsManager.rescheduleCampaign(campaignId, newSendAtISO)
794
+ .catch((err) => {
795
+ console.error('Failed to reschedule campaign:', err);
796
+ if (rollback) {
797
+ rollback();
798
+ }
799
+ });
626
800
  });
627
801
  });
628
802
  }
@@ -653,9 +827,4 @@ export default class CalendarRenderer {
653
827
  return `${display}${m > 0 ? ':' + String(m).padStart(2, '0') : ''}${period}`;
654
828
  }
655
829
 
656
- _escapeHtml(str) {
657
- const div = document.createElement('div');
658
- div.textContent = str;
659
- return div.innerHTML;
660
- }
661
830
  }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Campaign Preview
3
+ * Renders email (markdown) and push (mobile frame) previews
4
+ * for the campaign editor modal.
5
+ */
6
+
7
+ import { getPrerenderedIcon } from '__main_assets__/js/libs/prerendered-icons.js';
8
+ import { escapeHtml } from '__main_assets__/js/libs/admin-helpers.js';
9
+
10
+ // Lazy-loaded markdown-it instance
11
+ let md = null;
12
+
13
+ /**
14
+ * Render email campaign preview HTML.
15
+ * Lazy-loads markdown-it on first call.
16
+ */
17
+ async function renderEmailPreview(formData) {
18
+ const campaign = formData.campaign || {};
19
+ const subject = campaign.subject || '';
20
+ const preheader = campaign.preheader || '';
21
+ const content = campaign.content || '';
22
+
23
+ // Lazy-load markdown-it
24
+ if (!md) {
25
+ const MarkdownIt = (await import('markdown-it')).default;
26
+ md = new MarkdownIt({ html: true, breaks: true, linkify: true });
27
+ }
28
+
29
+ const renderedContent = content ? md.render(content) : '<p class="text-muted">No content yet</p>';
30
+
31
+ return `
32
+ <div class="email-preview">
33
+ <div class="email-preview-header">
34
+ <div class="email-preview-subject">${escapeHtml(subject) || '<span class="text-muted">No subject</span>'}</div>
35
+ ${preheader ? `<div class="email-preview-preheader">${escapeHtml(preheader)}</div>` : ''}
36
+ </div>
37
+ <div class="email-preview-body">${renderedContent}</div>
38
+ <div class="email-preview-disclaimer text-muted small mt-3">
39
+ ${getPrerenderedIcon('triangle-exclamation', 'fa-xs me-1')}
40
+ Preview shows formatted content. Final email may vary by template.
41
+ </div>
42
+ </div>
43
+ `;
44
+ }
45
+
46
+ /**
47
+ * Render push notification preview HTML (mobile device frame).
48
+ */
49
+ function renderPushPreview(formData) {
50
+ const campaign = formData.campaign || {};
51
+ const name = campaign.name || 'Notification Title';
52
+ const subject = campaign.subject || 'Notification body text...';
53
+ const icon = campaign.icon || '';
54
+
55
+ const iconSrc = icon && icon.match(/^https?:\/\/.+/)
56
+ ? escapeHtml(icon)
57
+ : 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="50" height="50"%3E%3Crect width="50" height="50" fill="%236c757d" rx="8"/%3E%3C/svg%3E';
58
+
59
+ const clickAction = campaign.clickAction || '';
60
+
61
+ return `
62
+ <div class="push-preview-frame">
63
+ <div class="push-preview-screen">
64
+ <div class="push-preview-status-bar">
65
+ <span>9:41 AM</span>
66
+ <span>
67
+ ${getPrerenderedIcon('wifi', 'fa-sm me-1')}
68
+ ${getPrerenderedIcon('battery-full', 'fa-sm')}
69
+ </span>
70
+ </div>
71
+ <div class="push-preview-notification"
72
+ ${clickAction ? `role="button" title="Click to test: ${escapeHtml(clickAction)}"` : ''}
73
+ data-click-action="${escapeHtml(clickAction)}">
74
+ <div class="d-flex align-items-start">
75
+ <img src="${iconSrc}"
76
+ class="rounded me-2"
77
+ width="50"
78
+ height="50"
79
+ onerror="this.src='data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2250%22 height=%2250%22%3E%3Crect width=%2250%22 height=%2250%22 fill=%22%236c757d%22 rx=%228%22/%3E%3C/svg%3E'">
80
+ <div class="flex-fill">
81
+ <div class="fw-semibold small">${escapeHtml(name)}</div>
82
+ <div class="small text-muted mt-1">${escapeHtml(subject)}</div>
83
+ <div class="small text-muted mt-1">
84
+ ${getPrerenderedIcon('clock', 'fa-xs me-1')}
85
+ Now
86
+ </div>
87
+ </div>
88
+ </div>
89
+ </div>
90
+ <div class="mt-2 opacity-50">
91
+ <div class="bg-body-secondary rounded p-2 small">
92
+ <div class="text-muted">Earlier notifications...</div>
93
+ </div>
94
+ </div>
95
+ </div>
96
+ </div>
97
+ `;
98
+ }
99
+
100
+ export { renderEmailPreview, renderPushPreview };
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Admin Calendar Page JavaScript
2
+ * Admin Marketing Calendar Page JavaScript
3
3
  */
4
4
 
5
5
  // Libraries
@@ -35,25 +35,13 @@ export default (Manager) => {
35
35
 
36
36
  // Initialize the calendar
37
37
  function initialize() {
38
- const core = new CalendarCore();
38
+ const core = new CalendarCore(webManager);
39
39
  const renderer = new CalendarRenderer(core);
40
40
  const events = new CalendarEvents(core, webManager);
41
41
 
42
42
  core.setRenderer(renderer);
43
43
  core.setEventsManager(events);
44
44
  core.initialize();
45
-
46
- // Expose public API
47
- window.calendarAPI = {
48
- addEvent: (data) => core.addEvent(data),
49
- updateEvent: (id, changes) => core.updateEvent(id, changes),
50
- removeEvent: (id) => core.removeEvent(id),
51
- getEvent: (id) => core.getEvent(id),
52
- getEvents: (filter) => core.getEvents(filter),
53
- navigate: (direction) => core.navigate(direction),
54
- setView: (mode) => core.setView(mode),
55
- goToToday: () => core.goToToday(),
56
- };
57
45
  }
58
46
 
59
47
  // Show unauthenticated state
@@ -62,8 +50,7 @@ function showUnauthenticated() {
62
50
  $grid.innerHTML = `
63
51
  <div class="d-flex align-items-center justify-content-center h-100 text-muted">
64
52
  <div class="text-center">
65
- <div class="mb-3" style="font-size: 3rem; opacity: 0.3;">📅</div>
66
- <p>Sign in to view the calendar</p>
53
+ <p>Sign in to view the marketing calendar</p>
67
54
  </div>
68
55
  </div>
69
56
  `;
@@ -66,15 +66,19 @@ function setupForm() {
66
66
 
67
67
  try {
68
68
  // Send request using wonderful-fetch
69
- await fetch(apiEndpoint, {
69
+ const response = await fetch(apiEndpoint, {
70
70
  method: 'POST',
71
71
  body: requestData,
72
72
  response: 'json',
73
73
  timeout: 30000,
74
74
  });
75
75
 
76
+ console.log('Contact form submitted successfully:', response);
77
+
76
78
  formManager.showSuccess('Thank you for your message! We\'ll get back to you within 24 hours.');
77
79
  } catch (error) {
80
+ console.error('Contact form submission failed:', error);
81
+
78
82
  // Only capture technical errors to Sentry (network, timeout, API errors)
79
83
  if (error.message?.includes('network') || error.message?.includes('timeout') || !error.message?.includes('Failed')) {
80
84
  webManager.sentry().captureException(new Error('Contact form submission error', { cause: error }));