sincerely 1.0.0 → 1.1.0

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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -0
  3. data/app/assets/config/sincerely.js +2 -0
  4. data/app/assets/images/sincerely/arrow_left.svg +1 -0
  5. data/app/assets/images/sincerely/check.svg +3 -0
  6. data/app/assets/images/sincerely/chevron_left.svg +1 -0
  7. data/app/assets/images/sincerely/clock.svg +1 -0
  8. data/app/assets/images/sincerely/code.svg +1 -0
  9. data/app/assets/images/sincerely/eye.svg +1 -0
  10. data/app/assets/images/sincerely/logout.svg +1 -0
  11. data/app/assets/images/sincerely/menu.svg +1 -0
  12. data/app/assets/images/sincerely/moon.svg +1 -0
  13. data/app/assets/images/sincerely/pencil.svg +4 -0
  14. data/app/assets/images/sincerely/plus.svg +3 -0
  15. data/app/assets/images/sincerely/progress_spinner.svg +3 -0
  16. data/app/assets/images/sincerely/spinner.svg +3 -0
  17. data/app/assets/images/sincerely/sun.svg +1 -0
  18. data/app/assets/images/sincerely/warning_circle.svg +4 -0
  19. data/app/assets/javascripts/sincerely/charts.js +115 -0
  20. data/app/assets/javascripts/sincerely/clickable_rows.js +29 -0
  21. data/app/assets/javascripts/sincerely/layout.js +104 -0
  22. data/app/assets/javascripts/sincerely/send_form.js +243 -0
  23. data/app/assets/javascripts/sincerely/template_form.js +44 -0
  24. data/app/assets/javascripts/sincerely/template_preview.js +12 -0
  25. data/app/assets/javascripts/sincerely/theme_init.js +9 -0
  26. data/app/assets/stylesheets/sincerely/charts.css +250 -0
  27. data/app/assets/stylesheets/sincerely/components.css +632 -0
  28. data/app/assets/stylesheets/sincerely/layout.css +233 -0
  29. data/app/assets/stylesheets/sincerely/navigation.css +198 -0
  30. data/app/assets/stylesheets/sincerely/send_form.css +287 -0
  31. data/app/assets/stylesheets/sincerely/template_form.css +74 -0
  32. data/app/assets/stylesheets/sincerely/template_preview.css +75 -0
  33. data/app/assets/stylesheets/sincerely/variables.css +88 -0
  34. data/app/controllers/sincerely/application_controller.rb +3 -72
  35. data/app/controllers/sincerely/concerns/authorization.rb +21 -0
  36. data/app/controllers/sincerely/concerns/delivery_metrics.rb +51 -0
  37. data/app/controllers/sincerely/concerns/event_filtering.rb +58 -0
  38. data/app/controllers/sincerely/concerns/event_timeline.rb +41 -0
  39. data/app/controllers/sincerely/concerns/notification_timeline.rb +73 -0
  40. data/app/controllers/sincerely/concerns/notifications.rb +47 -0
  41. data/app/controllers/sincerely/concerns/pagination.rb +39 -0
  42. data/app/controllers/sincerely/concerns/periods.rb +40 -0
  43. data/app/controllers/sincerely/dashboard_controller.rb +30 -88
  44. data/app/controllers/sincerely/delivery_events_controller.rb +13 -21
  45. data/app/controllers/sincerely/engagement_events_controller.rb +13 -22
  46. data/app/controllers/sincerely/notifications_controller.rb +68 -49
  47. data/app/controllers/sincerely/send_controller.rb +82 -67
  48. data/app/controllers/sincerely/templates_controller.rb +37 -8
  49. data/app/helpers/sincerely/application_helper.rb +22 -0
  50. data/app/helpers/sincerely/chart_helper.rb +79 -0
  51. data/app/views/layouts/sincerely/application.html.erb +24 -528
  52. data/app/views/sincerely/dashboard/index.html.erb +25 -256
  53. data/app/views/sincerely/delivery_events/index.html.erb +6 -38
  54. data/app/views/sincerely/engagement_events/index.html.erb +6 -38
  55. data/app/views/sincerely/notifications/index.html.erb +7 -23
  56. data/app/views/sincerely/notifications/show.html.erb +4 -4
  57. data/app/views/sincerely/send/new.html.erb +8 -522
  58. data/app/views/sincerely/shared/_event.html.erb +21 -0
  59. data/app/views/sincerely/shared/_line_chart.html.erb +38 -0
  60. data/app/views/sincerely/shared/_notification_type_filter.html.erb +9 -0
  61. data/app/views/sincerely/shared/_status_option.html.erb +1 -0
  62. data/app/views/sincerely/shared/_template_option.html.erb +3 -0
  63. data/app/views/sincerely/templates/_form.html.erb +8 -107
  64. data/app/views/sincerely/templates/edit.html.erb +2 -2
  65. data/app/views/sincerely/templates/index.html.erb +7 -15
  66. data/app/views/sincerely/templates/new.html.erb +2 -2
  67. data/app/views/sincerely/templates/preview.html.erb +7 -26
  68. data/app/views/sincerely/templates/show.html.erb +3 -3
  69. data/lib/config/sincerely_config.rb +2 -2
  70. data/lib/sincerely/delivery_systems/email_aws_ses.rb +2 -2
  71. data/lib/sincerely/engine.rb +4 -0
  72. data/lib/sincerely/mixins/notification_model.rb +5 -1
  73. data/lib/sincerely/services/process_delivery_event.rb +9 -7
  74. data/lib/sincerely/templates/notification_template.rb +1 -1
  75. data/lib/sincerely/version.rb +1 -1
  76. metadata +52 -4
  77. data/sincerely.gemspec +0 -37
@@ -42,9 +42,7 @@
42
42
  <button type="button" class="btn btn-primary" id="sendButton" disabled>
43
43
  <span class="btn-text">Send Emails</span>
44
44
  <span class="btn-loader" style="display: none;">
45
- <svg class="spinner" viewBox="0 0 24 24" fill="none" stroke="currentColor">
46
- <circle cx="12" cy="12" r="10" stroke-width="2" stroke-dasharray="32" stroke-linecap="round"></circle>
47
- </svg>
45
+ <%= inline_svg 'sincerely/spinner' %>
48
46
  Sending...
49
47
  </span>
50
48
  </button>
@@ -53,540 +51,28 @@
53
51
 
54
52
  <div class="send-overlay" id="sendOverlay">
55
53
  <div class="send-progress">
56
- <svg class="progress-spinner" viewBox="0 0 50 50">
57
- <circle cx="25" cy="25" r="20" fill="none" stroke-width="4"></circle>
58
- </svg>
54
+ <%= inline_svg 'sincerely/progress_spinner' %>
59
55
  <span class="progress-text">Sending emails...</span>
60
56
  </div>
61
57
  </div>
62
58
 
63
59
  <div class="send-result" id="sendResult" style="display: none;">
64
60
  <div class="result-icon result-success" id="resultSuccess">
65
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
66
- <path d="M20 6L9 17l-5-5"></path>
67
- </svg>
61
+ <%= inline_svg 'sincerely/check' %>
68
62
  </div>
69
63
  <div class="result-icon result-partial" id="resultPartial">
70
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
71
- <circle cx="12" cy="12" r="10"></circle>
72
- <path d="M12 8v4M12 16h.01"></path>
73
- </svg>
64
+ <%= inline_svg 'sincerely/warning_circle' %>
74
65
  </div>
75
66
  <div class="result-message" id="resultMessage"></div>
76
67
  <button type="button" class="btn btn-secondary" id="resetButton">Send More</button>
77
68
  </div>
78
69
  </div>
79
70
 
80
- <style>
81
- .recipients-limit {
82
- font-size: 0.75rem;
83
- color: var(--gray-500);
84
- background: var(--gray-100);
85
- padding: 0.25rem 0.5rem;
86
- border-radius: 0.25rem;
87
- }
88
71
 
89
- .send-form {
90
- position: relative;
91
- }
72
+ <%= stylesheet_link_tag 'sincerely/send_form' %>
92
73
 
93
- .tags-input-container {
94
- display: flex;
95
- flex-wrap: wrap;
96
- gap: 0.375rem;
97
- padding: 0.5rem;
98
- border: 1px solid var(--border-color);
99
- border-radius: 0.375rem;
100
- background: var(--bg-primary);
101
- min-height: 2.75rem;
102
- cursor: text;
103
- transition: border-color 0.15s, box-shadow 0.15s;
104
- }
74
+ <span id="templateVariablesUrl" data-url="<%= send_template_variables_path(':id') %>" hidden></span>
75
+ <span id="sendCreateUrl" data-url="<%= send_create_path %>" hidden></span>
105
76
 
106
- .tags-input-container:focus-within {
107
- border-color: var(--primary);
108
- box-shadow: 0 0 0 3px rgba(212, 24, 61, 0.1);
109
- }
77
+ <%= javascript_include_tag 'sincerely/send_form' %>
110
78
 
111
- .tags-list {
112
- display: flex;
113
- flex-wrap: wrap;
114
- gap: 0.375rem;
115
- }
116
-
117
- .tag {
118
- display: inline-flex;
119
- align-items: center;
120
- gap: 0.25rem;
121
- padding: 0.25rem 0.5rem;
122
- background: var(--gray-100);
123
- border-radius: 0.25rem;
124
- font-size: 0.8125rem;
125
- color: var(--text-primary);
126
- animation: tagIn 0.15s ease;
127
- }
128
-
129
- @keyframes tagIn {
130
- from { opacity: 0; transform: scale(0.9); }
131
- to { opacity: 1; transform: scale(1); }
132
- }
133
-
134
- .tag.invalid {
135
- background: #fee2e2;
136
- color: #dc2626;
137
- }
138
-
139
- .tag-remove {
140
- display: flex;
141
- align-items: center;
142
- justify-content: center;
143
- width: 1rem;
144
- height: 1rem;
145
- border: none;
146
- background: none;
147
- cursor: pointer;
148
- color: var(--gray-500);
149
- border-radius: 50%;
150
- transition: all 0.15s;
151
- }
152
-
153
- .tag-remove:hover {
154
- background: var(--gray-200);
155
- color: var(--text-primary);
156
- }
157
-
158
- .tag-remove svg {
159
- width: 0.75rem;
160
- height: 0.75rem;
161
- }
162
-
163
- .tags-input {
164
- flex: 1;
165
- min-width: 150px;
166
- border: none;
167
- outline: none;
168
- font-size: 0.875rem;
169
- background: transparent;
170
- color: var(--text-primary);
171
- }
172
-
173
- .tags-input::placeholder {
174
- color: var(--gray-500);
175
- }
176
-
177
- .recipients-count {
178
- margin-top: 0.5rem;
179
- font-size: 0.75rem;
180
- color: var(--gray-500);
181
- text-align: right;
182
- }
183
-
184
- .recipients-count.warning {
185
- color: #b45309;
186
- }
187
-
188
- .recipients-count.limit {
189
- color: #dc2626;
190
- }
191
-
192
- .template-variables {
193
- animation: slideDown 0.2s ease;
194
- }
195
-
196
- @keyframes slideDown {
197
- from { opacity: 0; transform: translateY(-0.5rem); }
198
- to { opacity: 1; transform: translateY(0); }
199
- }
200
-
201
- .variables-grid {
202
- display: grid;
203
- grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
204
- gap: 1rem;
205
- }
206
-
207
- .variable-field label {
208
- display: block;
209
- font-size: 0.75rem;
210
- font-weight: 500;
211
- color: var(--gray-500);
212
- margin-bottom: 0.25rem;
213
- }
214
-
215
- .variable-field input {
216
- width: 100%;
217
- padding: 0.5rem 0.75rem;
218
- border: 1px solid var(--border-color);
219
- border-radius: 0.375rem;
220
- font-size: 0.875rem;
221
- background: var(--bg-primary);
222
- color: var(--text-primary);
223
- transition: border-color 0.15s, box-shadow 0.15s;
224
- }
225
-
226
- .variable-field input:focus {
227
- outline: none;
228
- border-color: var(--primary);
229
- box-shadow: 0 0 0 3px rgba(212, 24, 61, 0.1);
230
- }
231
-
232
- .btn-loader {
233
- display: inline-flex;
234
- align-items: center;
235
- gap: 0.5rem;
236
- }
237
-
238
- .spinner {
239
- width: 1rem;
240
- height: 1rem;
241
- animation: spin 1s linear infinite;
242
- }
243
-
244
- @keyframes spin {
245
- from { transform: rotate(0deg); }
246
- to { transform: rotate(360deg); }
247
- }
248
-
249
- .send-overlay {
250
- position: absolute;
251
- inset: 0;
252
- background: rgba(255, 255, 255, 0.9);
253
- display: flex;
254
- align-items: center;
255
- justify-content: center;
256
- border-radius: 0.5rem;
257
- opacity: 0;
258
- visibility: hidden;
259
- transition: opacity 0.3s, visibility 0.3s;
260
- }
261
-
262
- [data-theme="dark"] .send-overlay {
263
- background: rgba(30, 30, 30, 0.9);
264
- }
265
-
266
- .send-overlay.active {
267
- opacity: 1;
268
- visibility: visible;
269
- }
270
-
271
- .send-progress {
272
- display: flex;
273
- flex-direction: column;
274
- align-items: center;
275
- gap: 1rem;
276
- }
277
-
278
- .progress-spinner {
279
- width: 3rem;
280
- height: 3rem;
281
- animation: spin 1s linear infinite;
282
- }
283
-
284
- .progress-spinner circle {
285
- stroke: var(--primary);
286
- stroke-dasharray: 90;
287
- stroke-dashoffset: 30;
288
- stroke-linecap: round;
289
- }
290
-
291
- .progress-text {
292
- font-size: 0.875rem;
293
- color: var(--text-secondary);
294
- }
295
-
296
- .send-result {
297
- display: flex;
298
- flex-direction: column;
299
- align-items: center;
300
- gap: 1rem;
301
- padding: 2rem;
302
- text-align: center;
303
- animation: fadeIn 0.3s ease;
304
- }
305
-
306
- @keyframes fadeIn {
307
- from { opacity: 0; }
308
- to { opacity: 1; }
309
- }
310
-
311
- .result-icon {
312
- width: 4rem;
313
- height: 4rem;
314
- border-radius: 50%;
315
- display: flex;
316
- align-items: center;
317
- justify-content: center;
318
- }
319
-
320
- .result-icon svg {
321
- width: 2rem;
322
- height: 2rem;
323
- }
324
-
325
- .result-success {
326
- background: #d1fae5;
327
- color: #047857;
328
- }
329
-
330
- .result-partial {
331
- background: #fef3c7;
332
- color: #b45309;
333
- display: none;
334
- }
335
-
336
- .result-message {
337
- font-size: 1rem;
338
- color: var(--text-primary);
339
- }
340
-
341
- .result-message strong {
342
- color: var(--success);
343
- }
344
-
345
- .result-message .failed {
346
- color: var(--danger);
347
- }
348
- </style>
349
-
350
- <script>
351
- (function() {
352
- const MAX_RECIPIENTS = 50;
353
- let recipients = [];
354
-
355
- const tagsContainer = document.getElementById('tagsContainer');
356
- const tagsList = document.getElementById('tagsList');
357
- const recipientInput = document.getElementById('recipientInput');
358
- const recipientsCount = document.getElementById('recipientsCount');
359
- const templateSelect = document.getElementById('templateSelect');
360
- const templateVariables = document.getElementById('templateVariables');
361
- const variablesGrid = document.getElementById('variablesGrid');
362
- const sendButton = document.getElementById('sendButton');
363
- const sendOverlay = document.getElementById('sendOverlay');
364
- const sendResult = document.getElementById('sendResult');
365
- const resultSuccess = document.getElementById('resultSuccess');
366
- const resultPartial = document.getElementById('resultPartial');
367
- const resultMessage = document.getElementById('resultMessage');
368
- const resetButton = document.getElementById('resetButton');
369
- const sendForm = document.querySelector('.send-form');
370
-
371
- function isValidEmail(email) {
372
- return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
373
- }
374
-
375
- function addRecipient(email) {
376
- email = email.trim().toLowerCase();
377
- if (!email || recipients.includes(email) || recipients.length >= MAX_RECIPIENTS) return false;
378
-
379
- recipients.push(email);
380
- renderTags();
381
- updateCount();
382
- updateSendButton();
383
- return true;
384
- }
385
-
386
- function removeRecipient(email) {
387
- recipients = recipients.filter(r => r !== email);
388
- renderTags();
389
- updateCount();
390
- updateSendButton();
391
- }
392
-
393
- function renderTags() {
394
- tagsList.innerHTML = recipients.map(email => `
395
- <span class="tag ${isValidEmail(email) ? '' : 'invalid'}">
396
- ${escapeHtml(email)}
397
- <button type="button" class="tag-remove" data-email="${escapeHtml(email)}">
398
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
399
- <path d="M18 6L6 18M6 6l12 12"></path>
400
- </svg>
401
- </button>
402
- </span>
403
- `).join('');
404
-
405
- tagsList.querySelectorAll('.tag-remove').forEach(btn => {
406
- btn.addEventListener('click', (e) => {
407
- e.stopPropagation();
408
- removeRecipient(btn.dataset.email);
409
- });
410
- });
411
- }
412
-
413
- function updateCount() {
414
- const count = recipients.length;
415
- recipientsCount.textContent = `${count} / ${MAX_RECIPIENTS} recipients`;
416
- recipientsCount.className = 'recipients-count';
417
- if (count >= MAX_RECIPIENTS) {
418
- recipientsCount.classList.add('limit');
419
- } else if (count >= 40) {
420
- recipientsCount.classList.add('warning');
421
- }
422
- }
423
-
424
- function updateSendButton() {
425
- const hasRecipients = recipients.filter(isValidEmail).length > 0;
426
- const hasTemplate = templateSelect.value !== '';
427
- sendButton.disabled = !hasRecipients || !hasTemplate;
428
- }
429
-
430
- function escapeHtml(text) {
431
- const div = document.createElement('div');
432
- div.textContent = text;
433
- return div.innerHTML;
434
- }
435
-
436
- function parseAndAddEmails(input) {
437
- const emails = input.split(/[\s,;]+/).filter(e => e.trim());
438
- emails.forEach(addRecipient);
439
- }
440
-
441
- // Input handlers
442
- recipientInput.addEventListener('keydown', (e) => {
443
- if (e.key === 'Enter' || e.key === ',' || e.key === ' ' || e.key === 'Tab') {
444
- e.preventDefault();
445
- const value = recipientInput.value.trim();
446
- if (value) {
447
- parseAndAddEmails(value);
448
- recipientInput.value = '';
449
- }
450
- } else if (e.key === 'Backspace' && !recipientInput.value && recipients.length) {
451
- removeRecipient(recipients[recipients.length - 1]);
452
- }
453
- });
454
-
455
- recipientInput.addEventListener('paste', (e) => {
456
- e.preventDefault();
457
- const pastedText = e.clipboardData.getData('text');
458
- parseAndAddEmails(pastedText);
459
- recipientInput.value = '';
460
- });
461
-
462
- recipientInput.addEventListener('blur', () => {
463
- const value = recipientInput.value.trim();
464
- if (value) {
465
- parseAndAddEmails(value);
466
- recipientInput.value = '';
467
- }
468
- });
469
-
470
- tagsContainer.addEventListener('click', () => {
471
- recipientInput.focus();
472
- });
473
-
474
- // Template selection
475
- templateSelect.addEventListener('change', async () => {
476
- updateSendButton();
477
-
478
- const templateId = templateSelect.value;
479
- if (!templateId) {
480
- templateVariables.style.display = 'none';
481
- return;
482
- }
483
-
484
- try {
485
- const response = await fetch(`<%= sincerely.send_template_variables_path(':id') %>`.replace(':id', templateId));
486
- const data = await response.json();
487
-
488
- if (data.variables && data.variables.length > 0) {
489
- variablesGrid.innerHTML = data.variables.map(v => {
490
- const label = v.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
491
- return `
492
- <div class="variable-field">
493
- <label for="var_${v}">${label}</label>
494
- <input type="text" id="var_${v}" name="template_data[${v}]" placeholder="${label}...">
495
- </div>
496
- `;
497
- }).join('');
498
- templateVariables.style.display = 'block';
499
- } else {
500
- templateVariables.style.display = 'none';
501
- }
502
- } catch (err) {
503
- console.error('Failed to load template variables:', err);
504
- templateVariables.style.display = 'none';
505
- }
506
- });
507
-
508
- // Send handler
509
- sendButton.addEventListener('click', async () => {
510
- const validRecipients = recipients.filter(isValidEmail);
511
- if (validRecipients.length === 0) return;
512
-
513
- const templateId = templateSelect.value;
514
- if (!templateId) return;
515
-
516
- // Collect template data
517
- const templateData = {};
518
- variablesGrid.querySelectorAll('input').forEach(input => {
519
- const name = input.name.match(/template_data\[(\w+)\]/);
520
- if (name) {
521
- templateData[name[1]] = input.value;
522
- }
523
- });
524
-
525
- // Show loading
526
- sendOverlay.classList.add('active');
527
- sendButton.querySelector('.btn-text').style.display = 'none';
528
- sendButton.querySelector('.btn-loader').style.display = 'inline-flex';
529
- sendButton.disabled = true;
530
-
531
- try {
532
- const response = await fetch('<%= sincerely.send_create_path %>', {
533
- method: 'POST',
534
- headers: {
535
- 'Content-Type': 'application/json',
536
- 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
537
- },
538
- body: JSON.stringify({
539
- recipients: validRecipients.join(','),
540
- template_id: templateId,
541
- template_data: templateData
542
- })
543
- });
544
-
545
- const result = await response.json();
546
-
547
- // Hide loading, show result
548
- sendOverlay.classList.remove('active');
549
- sendForm.style.display = 'none';
550
-
551
- if (result.failed > 0) {
552
- resultSuccess.style.display = 'none';
553
- resultPartial.style.display = 'flex';
554
- resultMessage.innerHTML = `<strong>${result.sent}</strong> emails sent, <span class="failed">${result.failed}</span> failed`;
555
- } else {
556
- resultSuccess.style.display = 'flex';
557
- resultPartial.style.display = 'none';
558
- resultMessage.innerHTML = `<strong>${result.sent}</strong> emails sent successfully!`;
559
- }
560
-
561
- sendResult.style.display = 'flex';
562
- } catch (err) {
563
- console.error('Send failed:', err);
564
- sendOverlay.classList.remove('active');
565
- sendButton.querySelector('.btn-text').style.display = 'inline';
566
- sendButton.querySelector('.btn-loader').style.display = 'none';
567
- sendButton.disabled = false;
568
- alert('Failed to send emails. Please try again.');
569
- }
570
- });
571
-
572
- // Reset handler
573
- resetButton.addEventListener('click', () => {
574
- recipients = [];
575
- renderTags();
576
- updateCount();
577
- templateSelect.value = '';
578
- templateVariables.style.display = 'none';
579
- variablesGrid.innerHTML = '';
580
- sendResult.style.display = 'none';
581
- sendForm.style.display = 'block';
582
- sendButton.querySelector('.btn-text').style.display = 'inline';
583
- sendButton.querySelector('.btn-loader').style.display = 'none';
584
- updateSendButton();
585
- recipientInput.focus();
586
- });
587
-
588
- // Initialize
589
- updateCount();
590
- updateSendButton();
591
- })();
592
- </script>
@@ -0,0 +1,21 @@
1
+ <% notification = notifications_by_message_id[event.message_id] %>
2
+ <tr<% if notification %> class="clickable-row" role="link" tabindex="0" data-href="<%= notification_path(notification) %>"<% end %>>
3
+ <td><span class="badge <%= event_type_badge_class(event.event_type) %>"><%= event.event_type %></span></td>
4
+ <td><%= truncate(event.recipient, length: recipient_length) %></td>
5
+ <% if detailed %>
6
+ <td><code><%= truncate(event.message_id, length: 20) %></code></td>
7
+ <td>
8
+ <% if event.options.present? %>
9
+ <details onclick="event.stopPropagation()">
10
+ <summary class="btn btn-secondary btn-sm">View</summary>
11
+ <pre class="event-details"><%= format_event_options(event.options) %></pre>
12
+ </details>
13
+ <% else %>
14
+ -
15
+ <% end %>
16
+ </td>
17
+ <td><%= format_timestamp(event_timestamp(event)) %></td>
18
+ <% else %>
19
+ <td><%= event_timestamp(event).strftime('%b %d, %H:%M') %></td>
20
+ <% end %>
21
+ </tr>
@@ -0,0 +1,38 @@
1
+ <% lc = line_chart_config(series, labels) %>
2
+ <div class="line-chart-container" id="lineChart">
3
+ <div class="line-chart-wrapper">
4
+ <svg class="line-chart" viewBox="0 0 <%= lc[:width] %> <%= lc[:height] %>" preserveAspectRatio="xMidYMid meet">
5
+ <%# Grid lines %>
6
+ <% 5.times do |i| %>
7
+ <% y = lc[:padding_top] + (lc[:plot_height] * i / 4.0) %>
8
+ <line class="line-chart-grid" x1="<%= lc[:padding_left] %>" y1="<%= y %>" x2="<%= lc[:width] - lc[:padding_right] %>" y2="<%= y %>" />
9
+ <text class="line-chart-label line-chart-label-y" x="<%= lc[:padding_left] - 8 %>" y="<%= y + 3 %>"><%= (lc[:max_y] * (4 - i) / 4.0).round %></text>
10
+ <% end %>
11
+
12
+ <%# X-axis labels (show subset to avoid overlap) %>
13
+ <% label_step = [1, (labels.length / 8.0).ceil].max %>
14
+ <% labels.each_with_index do |label, i| %>
15
+ <% if i % label_step == 0 || i == labels.length - 1 %>
16
+ <text class="line-chart-label line-chart-label-x" x="<%= lc[:padding_left] + (i * lc[:x_step]) %>" y="<%= lc[:height] - 8 %>"><%= label %></text>
17
+ <% end %>
18
+ <% end %>
19
+
20
+ <%# Lines for each state %>
21
+ <% series.each do |state, values| %>
22
+ <% points = line_chart_points(values, lc) %>
23
+ <polygon class="line-chart-area" data-state="<%= state %>" fill="<%= state_color(state) %>" points="<%= line_chart_area_points(points, values, lc) %>" />
24
+ <polyline class="line-chart-line" data-state="<%= state %>" stroke="<%= state_color(state) %>" points="<%= points %>" />
25
+ <% end %>
26
+ </svg>
27
+ </div>
28
+
29
+ <%# Legend %>
30
+ <div class="line-chart-legend">
31
+ <% series.each_key do |state| %>
32
+ <div class="line-chart-legend-item" data-state="<%= state %>">
33
+ <span class="donut-legend-color" style="background: <%= state_color(state) %>"></span>
34
+ <span class="donut-legend-text"><%= state.to_s.humanize %></span>
35
+ </div>
36
+ <% end %>
37
+ </div>
38
+ </div>
@@ -0,0 +1,9 @@
1
+ <div class="filter-group">
2
+ <label for="notification_type">Type</label>
3
+ <select name="notification_type" id="notification_type">
4
+ <option value="">All types</option>
5
+ <option value="email" <%= 'selected' if params[:notification_type] == 'email' %>>Email</option>
6
+ <option value="sms" <%= 'selected' if params[:notification_type] == 'sms' %>>SMS</option>
7
+ <option value="push" <%= 'selected' if params[:notification_type] == 'push' %>>Push</option>
8
+ </select>
9
+ </div>
@@ -0,0 +1 @@
1
+ <option value="<%= status_value %>" <%= 'selected' if params[param_name] == status_value %>><%= status_value.to_s.humanize %></option>
@@ -0,0 +1,3 @@
1
+ <option value="<%= template.id %>" <%= 'selected' if params[:template_id] == template.id.to_s %>>
2
+ <%= template.name %>
3
+ </option>