ultimate-jekyll-manager 1.6.5 → 1.6.7

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.
@@ -19,6 +19,7 @@ const TOGGLE_ID = 'marketing-emails';
19
19
  const GRANT_DATE_ID = 'marketing-emails-grant-date';
20
20
 
21
21
  let formManager = null;
22
+ let pushFormManager = null;
22
23
 
23
24
  export function init() {
24
25
  const $form = document.getElementById(FORM_ID);
@@ -90,4 +91,91 @@ export function loadData(account) {
90
91
  }
91
92
 
92
93
  formManager.ready();
94
+
95
+ initPushNotifications();
96
+ }
97
+
98
+ function updatePushUI() {
99
+ const $status = document.getElementById('push-notification-status');
100
+ const $tokenInput = document.getElementById('push-token-value');
101
+
102
+ if (!$status) {
103
+ return;
104
+ }
105
+
106
+ const notifications = webManager.notifications();
107
+ const stored = webManager.storage().get('notifications', {});
108
+ const permission = typeof Notification !== 'undefined' ? Notification.permission : 'default';
109
+
110
+ let state;
111
+ if (stored.subscribed && stored.token) {
112
+ state = 'subscribed';
113
+ $status.innerHTML = '<span class="badge bg-success">Subscribed</span>';
114
+ if ($tokenInput) { $tokenInput.value = stored.token; }
115
+ if (pushFormManager) { pushFormManager._setDisabled(true); }
116
+ } else if (!notifications.isSupported()) {
117
+ state = 'not-supported';
118
+ $status.innerHTML = '<span class="badge bg-warning">Not supported</span>';
119
+ if (pushFormManager) { pushFormManager._setDisabled(true); }
120
+ } else if (permission === 'denied') {
121
+ state = 'denied';
122
+ $status.innerHTML = '<span class="badge bg-danger">Denied</span>';
123
+ if (pushFormManager) { pushFormManager._setDisabled(true); }
124
+ } else {
125
+ state = 'not-subscribed';
126
+ $status.innerHTML = '<span class="badge bg-secondary">Not subscribed</span>';
127
+ if ($tokenInput) { $tokenInput.value = ''; }
128
+ if (pushFormManager) { pushFormManager.ready(); }
129
+ }
130
+
131
+ console.log('[Account:push] updatePushUI →', state, { storedSubscribed: stored.subscribed, storedToken: stored.token?.slice(-8), permission });
132
+ }
133
+
134
+ async function initPushNotifications() {
135
+ const $status = document.getElementById('push-notification-status');
136
+ const $form = document.getElementById('push-subscribe-form');
137
+ const $copyBtn = document.getElementById('copy-push-token-btn');
138
+
139
+ if (!$status) {
140
+ return;
141
+ }
142
+
143
+ const notifications = webManager.notifications();
144
+
145
+ // Full sync: validates permission + token + Firestore, then updates localStorage
146
+ await notifications.syncSubscription();
147
+
148
+ // Create the form (always starts disabled), then let updatePushUI control its state
149
+ if (notifications.isSupported() && $form) {
150
+ $form.style.display = '';
151
+
152
+ pushFormManager = new FormManager('#push-subscribe-form', {
153
+ autoReady: false,
154
+ allowResubmit: true,
155
+ });
156
+
157
+ pushFormManager.on('submit', async () => {
158
+ console.log('[Account:push] Subscribe button clicked');
159
+ await notifications.subscribe();
160
+ console.log('[Account:push] Subscribe complete — updating UI');
161
+ setTimeout(() => updatePushUI(), 0);
162
+ });
163
+ }
164
+
165
+ // Now that the form exists, let updatePushUI set the correct state
166
+ updatePushUI();
167
+
168
+ if ($copyBtn) {
169
+ const $tokenInput = document.getElementById('push-token-value');
170
+ const originalHtml = $copyBtn.innerHTML;
171
+ $copyBtn.addEventListener('click', () => {
172
+ if (!$tokenInput?.value) {
173
+ return;
174
+ }
175
+ navigator.clipboard.writeText($tokenInput.value).then(() => {
176
+ $copyBtn.textContent = 'Copied!';
177
+ setTimeout(() => { $copyBtn.innerHTML = originalHtml; }, 2000);
178
+ });
179
+ });
180
+ }
93
181
  }
@@ -32,7 +32,6 @@ export default class CalendarEvents {
32
32
  this._initTypeToggle();
33
33
  this._initRecurrenceToggle();
34
34
  this._initSendNow();
35
- this._initDeleteButton();
36
35
  this._initCreateButton();
37
36
  this._initPreview();
38
37
  }
@@ -47,6 +46,7 @@ export default class CalendarEvents {
47
46
  this._setType('email');
48
47
  this._resetRecurrence();
49
48
  document.getElementById('campaign-modal-title-text').textContent = 'Create Campaign';
49
+ document.getElementById('campaign-modal-id').classList.add('d-none');
50
50
  document.getElementById('campaign-local-time-row').style.display = 'none';
51
51
  });
52
52
 
@@ -86,15 +86,57 @@ export default class CalendarEvents {
86
86
  allowResubmit: true,
87
87
  });
88
88
 
89
- this.formManager.on('submit', async ({ data }) => {
90
- const payload = this._buildPayload(data);
89
+ this.formManager.on('validation', ({ $submitButton }) => {
90
+ const action = $submitButton?.value || 'save';
91
+ if (action === 'delete' || action === 'test') {
92
+ this.formManager.clearFieldErrors();
93
+ }
94
+ });
95
+
96
+ this.formManager.on('submit', async ({ data, $submitButton }) => {
97
+ const action = $submitButton?.value || 'save';
98
+
99
+ if (action === 'delete') {
100
+ if (!this.editingCampaignId) { return; }
101
+ if (!confirm('Delete this campaign? This cannot be undone.')) {
102
+ this.formManager.ready();
103
+ return;
104
+ }
105
+ await this._deleteCampaign(this.editingCampaignId);
106
+ this._getEditorModal().hide();
107
+ return;
108
+ }
109
+
110
+ if (action === 'test') {
111
+ const payload = this._buildPayload(data);
112
+ payload.test = true;
113
+ payload.sendAt = 'now';
114
+ delete payload.recurrence;
91
115
 
116
+ if (this.editingCampaignId) {
117
+ payload.recurringId = this.editingCampaignId;
118
+ }
119
+
120
+ const url = `${webManager.getApiUrl()}/backend-manager/marketing/campaign`;
121
+ await authorizedFetch(url, {
122
+ method: 'POST',
123
+ timeout: 60000,
124
+ response: 'json',
125
+ tries: 1,
126
+ log: true,
127
+ body: payload,
128
+ });
129
+ this.formManager.showSuccess('Test sent');
130
+ return;
131
+ }
132
+
133
+ // Default: save
134
+ const payload = this._buildPayload(data);
92
135
  if (this.editingCampaignId) {
93
136
  await this._updateCampaign(this.editingCampaignId, payload);
94
137
  } else {
95
138
  await this._createCampaign(payload);
96
139
  }
97
-
98
140
  this._getEditorModal().hide();
99
141
  });
100
142
  }
@@ -109,7 +151,7 @@ export default class CalendarEvents {
109
151
  const isEmail = $radio.value === 'email';
110
152
  $emailFields.classList.toggle('d-none', !isEmail);
111
153
  $pushFields.classList.toggle('d-none', isEmail);
112
- $subjectHint.textContent = isEmail ? '(email subject line)' : '(notification body text)';
154
+ $subjectHint.textContent = isEmail ? '(email subject line)' : '(notification body — campaign name is used as title)';
113
155
  });
114
156
  });
115
157
  }
@@ -204,27 +246,10 @@ export default class CalendarEvents {
204
246
  const now = new Date();
205
247
  document.getElementById('campaign-date').value = formatDateUTC(now);
206
248
  document.getElementById('campaign-time').value = formatTimeUTC(now);
249
+ this._updateLocalTimeHint();
207
250
  });
208
251
  }
209
252
 
210
- _initDeleteButton() {
211
- document.getElementById('btn-delete-campaign').addEventListener('click', async () => {
212
- if (!this.editingCampaignId) {
213
- return;
214
- }
215
-
216
- if (!confirm('Delete this campaign? This cannot be undone.')) {
217
- return;
218
- }
219
-
220
- try {
221
- await this._deleteCampaign(this.editingCampaignId);
222
- this._getEditorModal().hide();
223
- } catch (error) {
224
- this.formManager.showError(`Delete failed: ${error.message}`);
225
- }
226
- });
227
- }
228
253
 
229
254
  // ============================================
230
255
  // Modal Operations
@@ -233,6 +258,7 @@ export default class CalendarEvents {
233
258
  this.editingCampaignId = null;
234
259
  this.formManager.reset();
235
260
  this._toggleDeleteButton(false);
261
+ this._showCampaignId(null);
236
262
  this._setType('email');
237
263
  document.getElementById('campaign-modal-title-text').textContent = 'Create Campaign';
238
264
 
@@ -260,6 +286,7 @@ export default class CalendarEvents {
260
286
  }
261
287
  this.editingCampaignId = template.id;
262
288
  this._toggleDeleteButton(true);
289
+ this._showCampaignId(template.id);
263
290
  document.getElementById('campaign-modal-title-text').textContent = 'Edit Recurring Campaign';
264
291
  this._populateFormFromCampaign(template, true);
265
292
  this._getEditorModal().show();
@@ -274,10 +301,15 @@ export default class CalendarEvents {
274
301
  return;
275
302
  }
276
303
 
277
- // Recurring template: editable (changes apply to all future sends)
304
+ // Recurring template: editable only if still pending
278
305
  if (displayType === DISPLAY_TYPES.RECURRING_TEMPLATE) {
306
+ if (campaign.status === 'sent' || campaign.status === 'failed') {
307
+ this._openResultsModal(campaign);
308
+ return;
309
+ }
279
310
  this.editingCampaignId = campaignId;
280
311
  this._toggleDeleteButton(true);
312
+ this._showCampaignId(campaignId);
281
313
  document.getElementById('campaign-modal-title-text').textContent = 'Edit Recurring Campaign';
282
314
  this._populateFormFromCampaign(campaign, true);
283
315
  this._getEditorModal().show();
@@ -287,6 +319,7 @@ export default class CalendarEvents {
287
319
  // One-off pending: fully editable
288
320
  this.editingCampaignId = campaignId;
289
321
  this._toggleDeleteButton(true);
322
+ this._showCampaignId(campaignId);
290
323
  document.getElementById('campaign-modal-title-text').textContent = 'Edit Campaign';
291
324
  this._populateFormFromCampaign(campaign, true);
292
325
  this._getEditorModal().show();
@@ -323,7 +356,8 @@ export default class CalendarEvents {
323
356
  document.getElementById('campaign-subject').value = settings.subject || '';
324
357
  document.getElementById('campaign-date').value = formatDateUTC(d);
325
358
  document.getElementById('campaign-time').value = formatTimeUTC(d);
326
- document.getElementById('campaign-discount-code').value = settings.discountCode || '';
359
+ const contentData = settings.data?.content || {};
360
+ document.getElementById('campaign-discount-code').value = contentData.discountCode || '';
327
361
  document.getElementById('campaign-test').checked = !!settings.test;
328
362
 
329
363
  // Targeting
@@ -333,8 +367,8 @@ export default class CalendarEvents {
333
367
 
334
368
  // Email fields
335
369
  document.getElementById('campaign-preheader').value = settings.preheader || '';
336
- document.getElementById('campaign-content').value = settings.content || '';
337
- document.getElementById('campaign-template').value = settings.template || 'default';
370
+ document.getElementById('campaign-content').value = contentData.message || '';
371
+ document.getElementById('campaign-template').value = settings.template || 'card';
338
372
  document.getElementById('campaign-sender').value = settings.sender || 'marketing';
339
373
 
340
374
  // Push fields
@@ -409,6 +443,16 @@ export default class CalendarEvents {
409
443
  }
410
444
  }
411
445
 
446
+ _showCampaignId(id) {
447
+ const $el = document.getElementById('campaign-modal-id');
448
+ if (id) {
449
+ $el.textContent = id;
450
+ $el.classList.remove('d-none');
451
+ } else {
452
+ $el.classList.add('d-none');
453
+ }
454
+ }
455
+
412
456
  _updateLocalTimeHint() {
413
457
  const $row = document.getElementById('campaign-local-time-row');
414
458
  const $hint = document.getElementById('campaign-time-local');
@@ -456,9 +500,6 @@ export default class CalendarEvents {
456
500
  };
457
501
 
458
502
  // Config
459
- if (c.discountCode) {
460
- payload.discountCode = c.discountCode.trim();
461
- }
462
503
  if (c.test) {
463
504
  payload.test = true;
464
505
  }
@@ -492,15 +533,23 @@ export default class CalendarEvents {
492
533
  if (c.preheader) {
493
534
  payload.preheader = c.preheader.trim();
494
535
  }
495
- if (c.content) {
496
- payload.content = c.content;
497
- }
498
- if (c.template && c.template !== 'default') {
536
+ if (c.template && c.template !== 'card') {
499
537
  payload.template = c.template;
500
538
  }
501
539
  if (c.sender) {
502
540
  payload.sender = c.sender;
503
541
  }
542
+
543
+ const content = {};
544
+ if (c.content) {
545
+ content.message = c.content;
546
+ }
547
+ if (c.discountCode) {
548
+ content.discountCode = c.discountCode.trim();
549
+ }
550
+ if (Object.keys(content).length) {
551
+ payload.data = { ...payload.data, content };
552
+ }
504
553
  }
505
554
 
506
555
  // Push-specific
@@ -732,9 +781,12 @@ export default class CalendarEvents {
732
781
  html += `<span class="badge bg-${campaign.type === 'email' ? 'primary' : 'success'}">${campaign.type === 'email' ? 'Email' : 'Push'}</span>`;
733
782
  html += `</div>`;
734
783
  html += `<table class="table table-sm table-borderless mb-0">`;
735
- html += `<tr><td class="text-muted" style="width:120px">Name</td><td>${webManager.utilities().escapeHTML(settings.name || '')}</td></tr>`;
784
+ html += `<tr><td class="text-muted" style="width:120px">ID</td><td><code>${webManager.utilities().escapeHTML(campaign.id)}</code></td></tr>`;
785
+ html += `<tr><td class="text-muted">Name</td><td>${webManager.utilities().escapeHTML(settings.name || '')}</td></tr>`;
736
786
  html += `<tr><td class="text-muted">Subject</td><td>${webManager.utilities().escapeHTML(settings.subject || '')}</td></tr>`;
737
- html += `<tr><td class="text-muted">Sent At</td><td>${formatDateUTC(d)} ${formatTimeUTC(d)} UTC</td></tr>`;
787
+ html += `<tr><td class="text-muted">Test</td><td>${settings.test ? '<span class="badge bg-warning">Yes</span>' : 'No'}</td></tr>`;
788
+ const localStr = d.toLocaleString('en', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit', timeZoneName: 'short' });
789
+ html += `<tr><td class="text-muted">Sent At</td><td>${formatDateUTC(d)} ${formatTimeUTC(d)} UTC <span class="text-muted">(${localStr})</span></td></tr>`;
738
790
 
739
791
  if (campaign.type === 'email') {
740
792
  if (settings.preheader) {
@@ -767,10 +819,11 @@ export default class CalendarEvents {
767
819
  html += '</div>';
768
820
 
769
821
  // Content (email only)
770
- if (campaign.type === 'email' && settings.content) {
822
+ const resultContent = settings.data?.content?.message;
823
+ if (campaign.type === 'email' && resultContent) {
771
824
  html += '<div class="mb-4">';
772
825
  html += '<h6>Content</h6>';
773
- 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>`;
826
+ html += `<pre class="bg-body-tertiary p-3 rounded small" style="white-space:pre-wrap;max-height:200px;overflow-y:auto">${webManager.utilities().escapeHTML(resultContent)}</pre>`;
774
827
  html += '</div>';
775
828
  }
776
829
 
@@ -14,6 +14,18 @@ export default class CalendarRenderer {
14
14
  this.$toolbar = document.getElementById('calendar-toolbar');
15
15
  this.$grid = document.getElementById('calendar-grid');
16
16
  this._nowLineInterval = null;
17
+ this._resizeObserver = null;
18
+
19
+ this._initResizeObserver();
20
+ }
21
+
22
+ _initResizeObserver() {
23
+ this._resizeObserver = new ResizeObserver(() => {
24
+ if (this.core.viewMode === 'month') {
25
+ this._fitMonthEvents();
26
+ }
27
+ });
28
+ this._resizeObserver.observe(this.$grid);
17
29
  }
18
30
 
19
31
  // ============================================
@@ -90,6 +102,7 @@ export default class CalendarRenderer {
90
102
  // Month View
91
103
  // ============================================
92
104
  _renderMonthView() {
105
+ this._pillHeight = null;
93
106
  const core = this.core;
94
107
  const cells = core.getMonthGrid();
95
108
 
@@ -120,13 +133,26 @@ export default class CalendarRenderer {
120
133
  html += '</div>';
121
134
 
122
135
  this.$grid.innerHTML = html;
123
- this._fitMonthEvents();
136
+ requestAnimationFrame(() => this._fitMonthEvents());
124
137
  this._bindCellClicks();
125
138
  this._bindCampaignClicks();
126
139
  this._bindDragAndDrop();
127
140
  }
128
141
 
129
142
  _fitMonthEvents() {
143
+ // Measure natural pill height once from a detached clone so flex
144
+ // compression doesn't skew the value
145
+ if (!this._pillHeight) {
146
+ const $sample = this.$grid.querySelector('.calendar-event');
147
+ if ($sample) {
148
+ const $clone = $sample.cloneNode(true);
149
+ $clone.style.cssText = 'position:absolute;visibility:hidden;pointer-events:none;';
150
+ document.body.appendChild($clone);
151
+ this._pillHeight = $clone.getBoundingClientRect().height;
152
+ $clone.remove();
153
+ }
154
+ }
155
+
130
156
  this.$grid.querySelectorAll('.calendar-cell').forEach(($cell) => {
131
157
  const $events = $cell.querySelector('.calendar-cell-events');
132
158
  if (!$events) { return; }
@@ -135,12 +161,15 @@ export default class CalendarRenderer {
135
161
  const $more = $events.querySelector('.calendar-cell-more');
136
162
  if ($pills.length === 0) { return; }
137
163
 
138
- // Available height = cell inner height minus everything above the events container
164
+ // Hide everything first so the cell has its natural empty height
165
+ $pills.forEach(($p) => { $p.style.display = 'none'; });
166
+ if ($more) { $more.style.display = 'none'; }
167
+
139
168
  const cellRect = $cell.getBoundingClientRect();
140
169
  const eventsRect = $events.getBoundingClientRect();
141
170
  const available = cellRect.bottom - eventsRect.top;
142
171
 
143
- const pillHeight = $pills[0].getBoundingClientRect().height;
172
+ const pillHeight = this._pillHeight || 20;
144
173
  const gap = 1;
145
174
 
146
175
  // Measure "+more" line height
@@ -151,24 +180,26 @@ export default class CalendarRenderer {
151
180
  const moreLineHeight = $more ? $more.getBoundingClientRect().height + gap : 0;
152
181
  if ($more) { $more.style.display = 'none'; }
153
182
 
154
- let shown = 0;
155
-
156
- for (let i = 0; i < $pills.length; i++) {
157
- const heightSoFar = (shown + 1) * pillHeight + shown * gap;
158
- const remaining = $pills.length - i - 1;
159
- const wouldNeedMore = remaining > 0;
160
- const totalNeeded = heightSoFar + (wouldNeedMore ? moreLineHeight : 0);
161
-
162
- if (totalNeeded <= available) {
163
- $pills[i].style.display = '';
164
- shown++;
183
+ // Calculate how many pills fit
184
+ let maxVisible = 0;
185
+ for (let n = 1; n <= $pills.length; n++) {
186
+ const pillsHeight = n * pillHeight + (n - 1) * gap;
187
+ const needsMore = n < $pills.length;
188
+ const total = pillsHeight + (needsMore ? moreLineHeight : 0);
189
+ if (total <= available) {
190
+ maxVisible = n;
165
191
  } else {
166
- $pills[i].style.display = 'none';
192
+ break;
167
193
  }
168
194
  }
169
195
 
196
+ // Apply visibility
197
+ $pills.forEach(($p, i) => {
198
+ $p.style.display = i < maxVisible ? '' : 'none';
199
+ });
200
+
170
201
  if ($more) {
171
- const hidden = $pills.length - shown;
202
+ const hidden = $pills.length - maxVisible;
172
203
  if (hidden > 0) {
173
204
  $more.textContent = `+${hidden} more`;
174
205
  $more.style.display = '';
@@ -60,6 +60,7 @@ prerender_icons:
60
60
  - name: "wifi"
61
61
  - name: "battery-full"
62
62
  - name: "table-list"
63
+ - name: "vial"
63
64
  ---
64
65
 
65
66
  <!-- Calendar Root -->
@@ -79,6 +80,7 @@ prerender_icons:
79
80
  <h5 class="modal-title" id="campaign-modal-title">
80
81
  {% uj_icon "plus", "fa-md me-2" %}
81
82
  <span id="campaign-modal-title-text">Create campaign</span>
83
+ <small class="text-muted fw-normal ms-2 d-none" id="campaign-modal-id" style="font-size: 0.6rem"></small>
82
84
  </h5>
83
85
  <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
84
86
  </div>
@@ -87,7 +89,7 @@ prerender_icons:
87
89
 
88
90
  <!-- Campaign Type -->
89
91
  <h6 class="mb-3">
90
- {% uj_icon "bullhorn", "fa-sm me-2 text-info" %}
92
+ {% uj_icon "bullhorn", "me-2 text-info" %}
91
93
  Campaign type
92
94
  </h6>
93
95
  <div class="row mb-4">
@@ -186,9 +188,8 @@ prerender_icons:
186
188
  <div class="col-md-6">
187
189
  <label for="campaign-template" class="form-label">Template</label>
188
190
  <select class="form-select" id="campaign-template" name="campaign.template">
189
- <option value="default" selected>Default</option>
190
- <option value="main/basic/card">Card</option>
191
- <option value="main/basic/plain">Plain</option>
191
+ <option value="card" selected>Card</option>
192
+ <option value="plain">Plain</option>
192
193
  </select>
193
194
  </div>
194
195
  <div class="col-md-6">
@@ -201,6 +202,18 @@ prerender_icons:
201
202
  </select>
202
203
  </div>
203
204
  </div>
205
+
206
+ <div class="mb-3">
207
+ <label for="campaign-discount-code" class="form-label">
208
+ Discount code
209
+ <small class="text-muted fw-normal ms-1">(optional — resolves {discount.code} and {discount.percent})</small>
210
+ </label>
211
+ <input type="text"
212
+ class="form-control"
213
+ id="campaign-discount-code"
214
+ name="campaign.discountCode"
215
+ placeholder="e.g. UPGRADE15">
216
+ </div>
204
217
  </div>
205
218
 
206
219
  <!-- Push-only fields -->
@@ -271,19 +284,6 @@ prerender_icons:
271
284
  </div>
272
285
  </div>
273
286
 
274
- <!-- Discount Code -->
275
- <div class="mb-3">
276
- <label for="campaign-discount-code" class="form-label">
277
- Discount code
278
- <small class="text-muted fw-normal ms-1">(optional)</small>
279
- </label>
280
- <input type="text"
281
- class="form-control"
282
- id="campaign-discount-code"
283
- name="campaign.discountCode"
284
- placeholder="e.g. UPGRADE15">
285
- </div>
286
-
287
287
  <!-- Test Mode -->
288
288
  <div class="form-check mb-4">
289
289
  <input class="form-check-input"
@@ -300,7 +300,7 @@ prerender_icons:
300
300
 
301
301
  <!-- Schedule -->
302
302
  <h6 class="mb-3 mt-4">
303
- {% uj_icon "clock", "fa-sm me-2 text-info" %}
303
+ {% uj_icon "clock", "me-2 text-info" %}
304
304
  Schedule
305
305
  <small class="text-muted fw-normal ms-1">(UTC)</small>
306
306
  </h6>
@@ -330,9 +330,9 @@ prerender_icons:
330
330
  <div class="invalid-feedback">Time is required</div>
331
331
  </div>
332
332
  <div class="col-md-3 d-flex align-items-end">
333
- <button type="button" class="btn btn-sm btn-outline-primary w-100" id="btn-send-now">
334
- {% uj_icon "paper-plane", "fa-sm me-1" %}
335
- Send now
333
+ <button type="button" class="btn btn-md btn-outline-primary w-100" id="btn-send-now">
334
+ {% uj_icon "clock", "fa-sm me-1" %}
335
+ Set now
336
336
  </button>
337
337
  </div>
338
338
  </div>
@@ -342,7 +342,7 @@ prerender_icons:
342
342
 
343
343
  <!-- Recurrence -->
344
344
  <h6 class="mb-3 mt-4">
345
- {% uj_icon "repeat", "fa-sm me-2 text-info" %}
345
+ {% uj_icon "repeat", "me-2 text-info" %}
346
346
  Recurrence
347
347
  <small class="text-muted fw-normal ms-1">(optional)</small>
348
348
  </h6>
@@ -445,7 +445,7 @@ prerender_icons:
445
445
 
446
446
  <!-- Targeting -->
447
447
  <h6 class="mb-3 mt-4">
448
- {% uj_icon "users", "fa-sm me-2 text-success" %}
448
+ {% uj_icon "users", "me-2 text-success" %}
449
449
  Targeting
450
450
  </h6>
451
451
 
@@ -499,32 +499,33 @@ prerender_icons:
499
499
 
500
500
  <div class="mb-3">
501
501
  <label for="campaign-segments" class="form-label">
502
- Segments
503
- <small class="text-muted fw-normal ms-1">(comma-separated IDs)</small>
502
+ Include segments
503
+ <small class="text-muted fw-normal ms-1">(AND — contacts must match ALL listed segments)</small>
504
504
  </label>
505
505
  <input type="text"
506
506
  class="form-control"
507
507
  id="campaign-segments"
508
508
  name="campaign.segments"
509
- placeholder="e.g. seg_abc123, seg_def456">
509
+ placeholder="e.g. subscription_free, lifecycle_30d">
510
+ <small class="form-text text-muted">SSOT keys: subscription_free, subscription_paid, subscription_trialing, subscription_cancelled, subscription_churned_trial, subscription_churned_paid, lifecycle_7d, lifecycle_30d, engagement_active_30d, engagement_inactive_90d, etc.</small>
510
511
  </div>
511
512
 
512
513
  <div class="mb-3">
513
514
  <label for="campaign-exclude-segments" class="form-label">
514
515
  Exclude segments
515
- <small class="text-muted fw-normal ms-1">(comma-separated IDs)</small>
516
+ <small class="text-muted fw-normal ms-1">(AND — contacts matching ANY excluded segment are removed)</small>
516
517
  </label>
517
518
  <input type="text"
518
519
  class="form-control"
519
520
  id="campaign-exclude-segments"
520
521
  name="campaign.excludeSegments"
521
- placeholder="e.g. seg_unsubscribed">
522
+ placeholder="e.g. subscription_paid, subscription_trialing">
522
523
  </div>
523
524
 
524
525
  <!-- Advanced Settings (collapsible) -->
525
526
  <h6 class="mb-3 mt-4">
526
527
  <a class="text-decoration-none" data-bs-toggle="collapse" href="#advanced-settings" role="button" aria-expanded="false">
527
- {% uj_icon "sliders", "fa-sm me-2 text-info" %}
528
+ {% uj_icon "sliders", "me-2 text-info" %}
528
529
  Advanced settings
529
530
  <small class="text-muted fw-normal ms-1">(optional)</small>
530
531
  </a>
@@ -575,23 +576,26 @@ prerender_icons:
575
576
  </div>
576
577
  </div>
577
578
 
579
+ <div class="modal-footer d-flex justify-content-between">
580
+ <div>
581
+ <button type="submit" name="action" value="delete" formnovalidate class="btn btn-outline-danger d-none" id="btn-delete-campaign">
582
+ {% uj_icon "trash", "fa-sm me-1" %}
583
+ <span class="button-text">Delete</span>
584
+ </button>
585
+ </div>
586
+ <div class="d-flex gap-2">
587
+ <button type="submit" name="action" value="test" formnovalidate class="btn btn-outline-warning" id="btn-send-test">
588
+ {% uj_icon "vial", "fa-sm me-1" %}
589
+ <span class="button-text">Send test</span>
590
+ </button>
591
+ <button type="submit" name="action" value="save" class="btn btn-adaptive" id="btn-save-campaign">
592
+ {% uj_icon "paper-plane", "fa-sm me-1" %}
593
+ <span class="button-text">Save campaign</span>
594
+ </button>
595
+ </div>
596
+ </div>
578
597
  </form>
579
598
  </div>
580
- <div class="modal-footer d-flex justify-content-between">
581
- <div>
582
- <button type="button" class="btn btn-outline-danger d-none" id="btn-delete-campaign">
583
- {% uj_icon "trash", "fa-sm me-1" %}
584
- Delete
585
- </button>
586
- </div>
587
- <div class="d-flex gap-2">
588
- <button type="button" class="btn btn-outline-adaptive" data-bs-dismiss="modal">Cancel</button>
589
- <button type="submit" form="campaign-editor-form" class="btn btn-adaptive" id="btn-save-campaign">
590
- {% uj_icon "paper-plane", "fa-sm me-1" %}
591
- <span class="button-text">Save campaign</span>
592
- </button>
593
- </div>
594
- </div>
595
599
  </div>
596
600
  </div>
597
601
  </div>
@@ -1277,6 +1277,36 @@ badges:
1277
1277
  </form>
1278
1278
  </div>
1279
1279
  </div>
1280
+ <div class="card mt-3">
1281
+ <div class="card-body">
1282
+ <div class="d-flex justify-content-between align-items-center mb-1">
1283
+ <h5 class="card-title mb-0">Push notifications</h5>
1284
+ <span id="push-notification-status"><span class="badge bg-secondary">Checking...</span></span>
1285
+ </div>
1286
+ <p class="text-muted small mb-3">Receive push notifications in your browser for important updates.</p>
1287
+
1288
+ <form id="push-subscribe-form" novalidate onsubmit="return false" style="display:none">
1289
+ <button type="submit" class="btn btn-adaptive btn-sm" id="push-subscribe-btn">
1290
+ {% uj_icon "bell", "me-1" %}
1291
+ <span class="button-text">Enable push notifications</span>
1292
+ </button>
1293
+ </form>
1294
+
1295
+ <div class="mt-3 pt-3 border-top">
1296
+ <a class="small text-muted text-decoration-none d-flex align-items-center" data-bs-toggle="collapse" href="#push-token-details" role="button" aria-expanded="false">
1297
+ {% uj_icon "code", "fa-xs me-1" %} Token details
1298
+ </a>
1299
+ <div class="collapse mt-2" id="push-token-details">
1300
+ <div class="input-group input-group-sm">
1301
+ <input type="text" class="form-control font-monospace bg-body-tertiary" id="push-token-value" readonly value="No token" style="font-size: 0.65rem;">
1302
+ <button type="button" class="btn btn-outline-adaptive" id="copy-push-token-btn">
1303
+ {% uj_icon "copy" %}
1304
+ </button>
1305
+ </div>
1306
+ </div>
1307
+ </div>
1308
+ </div>
1309
+ </div>
1280
1310
  </section>
1281
1311
 
1282
1312
  <!-- API Keys Section -->
@@ -77,6 +77,9 @@ web_manager:
77
77
  messagingSenderId: ""
78
78
  appId: ""
79
79
  measurementId: ""
80
+ messaging:
81
+ config:
82
+ vapidKey: ""
80
83
  appCheck:
81
84
  enabled: false
82
85
  config:
@@ -145,6 +145,27 @@ class Manager {
145
145
  // Initialize messaging
146
146
  this.libraries.messaging = firebase.messaging();
147
147
 
148
+ // Handle background push messages (when the page is not focused)
149
+ this.libraries.messaging.onBackgroundMessage((payload) => {
150
+ console.log('Background message received:', payload);
151
+
152
+ const notification = payload.notification || {};
153
+ const data = payload.data || {};
154
+
155
+ const title = notification.title || 'New notification';
156
+ const options = {
157
+ body: notification.body || '',
158
+ icon: notification.icon || notification.image || '/assets/images/favicon/favicon-192x192.png',
159
+ data: payload,
160
+ };
161
+
162
+ if (data.click_action) {
163
+ options.data.click_action = data.click_action;
164
+ }
165
+
166
+ serviceWorker.registration.showNotification(title, options);
167
+ });
168
+
148
169
  // Attach firebase to SWManager
149
170
  this.libraries.firebase = firebase;
150
171
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ultimate-jekyll-manager",
3
- "version": "1.6.5",
3
+ "version": "1.6.7",
4
4
  "description": "Ultimate Jekyll dependency manager",
5
5
  "main": "dist/index.js",
6
6
  "exports": {
@@ -108,7 +108,7 @@
108
108
  "prettier": "^3.8.3",
109
109
  "sass": "^1.100.0",
110
110
  "spellchecker": "^3.7.1",
111
- "web-manager": "^4.2.0",
111
+ "web-manager": "^4.3.1",
112
112
  "webpack": "^5.107.2",
113
113
  "wonderful-fetch": "^2.0.5",
114
114
  "wonderful-version": "^1.3.2",