sincerely 1.0.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 (63) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +38 -0
  4. data/CHANGELOG.md +12 -0
  5. data/README.md +202 -0
  6. data/Rakefile +12 -0
  7. data/app/controllers/sincerely/application_controller.rb +81 -0
  8. data/app/controllers/sincerely/dashboard_controller.rb +112 -0
  9. data/app/controllers/sincerely/delivery_events_controller.rb +31 -0
  10. data/app/controllers/sincerely/engagement_events_controller.rb +32 -0
  11. data/app/controllers/sincerely/notifications_controller.rb +69 -0
  12. data/app/controllers/sincerely/send_controller.rb +105 -0
  13. data/app/controllers/sincerely/templates_controller.rb +61 -0
  14. data/app/helpers/sincerely/application_helper.rb +39 -0
  15. data/app/views/layouts/sincerely/application.html.erb +593 -0
  16. data/app/views/sincerely/dashboard/index.html.erb +382 -0
  17. data/app/views/sincerely/delivery_events/index.html.erb +97 -0
  18. data/app/views/sincerely/engagement_events/index.html.erb +97 -0
  19. data/app/views/sincerely/notifications/index.html.erb +91 -0
  20. data/app/views/sincerely/notifications/show.html.erb +98 -0
  21. data/app/views/sincerely/send/new.html.erb +592 -0
  22. data/app/views/sincerely/shared/_pagination.html.erb +19 -0
  23. data/app/views/sincerely/templates/_form.html.erb +226 -0
  24. data/app/views/sincerely/templates/edit.html.erb +11 -0
  25. data/app/views/sincerely/templates/index.html.erb +59 -0
  26. data/app/views/sincerely/templates/new.html.erb +11 -0
  27. data/app/views/sincerely/templates/preview.html.erb +48 -0
  28. data/app/views/sincerely/templates/show.html.erb +69 -0
  29. data/config/routes.rb +21 -0
  30. data/lib/config/application_config.rb +18 -0
  31. data/lib/config/sincerely_config.rb +31 -0
  32. data/lib/generators/sincerely/aws_ses_webhook_controller_generator.rb +31 -0
  33. data/lib/generators/sincerely/events_generator.rb +45 -0
  34. data/lib/generators/sincerely/install_generator.rb +18 -0
  35. data/lib/generators/sincerely/migration_generator.rb +65 -0
  36. data/lib/generators/templates/aws_ses_webhook_controller.rb.erb +3 -0
  37. data/lib/generators/templates/events/delivery_event_model.rb.erb +5 -0
  38. data/lib/generators/templates/events/delivery_events_create.rb.erb +20 -0
  39. data/lib/generators/templates/events/engagement_event_model.rb.erb +5 -0
  40. data/lib/generators/templates/events/engagement_events_create.rb.erb +21 -0
  41. data/lib/generators/templates/notification_model.rb.erb +3 -0
  42. data/lib/generators/templates/notifications_create.rb.erb +21 -0
  43. data/lib/generators/templates/notifications_update.rb.erb +16 -0
  44. data/lib/generators/templates/sincerely.yml +21 -0
  45. data/lib/generators/templates/templates_create.rb.erb +15 -0
  46. data/lib/sincerely/delivery_systems/email_aws_ses.rb +69 -0
  47. data/lib/sincerely/engine.rb +7 -0
  48. data/lib/sincerely/mixins/notification_model.rb +94 -0
  49. data/lib/sincerely/mixins/webhooks/aws_ses_events.rb +74 -0
  50. data/lib/sincerely/renderers/liquid.rb +14 -0
  51. data/lib/sincerely/services/events/aws_ses_bounce_event.rb +26 -0
  52. data/lib/sincerely/services/events/aws_ses_click_event.rb +25 -0
  53. data/lib/sincerely/services/events/aws_ses_complaint_event.rb +25 -0
  54. data/lib/sincerely/services/events/aws_ses_delivery_event.rb +17 -0
  55. data/lib/sincerely/services/events/aws_ses_event.rb +56 -0
  56. data/lib/sincerely/services/events/aws_ses_open_event.rb +25 -0
  57. data/lib/sincerely/services/process_delivery_event.rb +72 -0
  58. data/lib/sincerely/templates/email_liquid_template.rb +13 -0
  59. data/lib/sincerely/templates/notification_template.rb +22 -0
  60. data/lib/sincerely/version.rb +5 -0
  61. data/lib/sincerely.rb +20 -0
  62. data/sincerely.gemspec +37 -0
  63. metadata +187 -0
@@ -0,0 +1,98 @@
1
+ <div class="page-header page-header--visible">
2
+ <div class="page-header-top">
3
+ <button onclick="history.back()" class="btn-back" title="Back">
4
+ <svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path></svg>
5
+ </button>
6
+ <h1 class="page-title">Notification #<%= @notification.id %></h1>
7
+ </div>
8
+ <p class="page-subtitle">Sent to <%= @notification.recipient %></p>
9
+ </div>
10
+
11
+ <div class="grid grid-2">
12
+ <div class="card">
13
+ <div class="card-header">
14
+ <h2 class="card-title">Details</h2>
15
+ </div>
16
+ <div class="details-grid">
17
+ <div class="detail-row">
18
+ <span class="detail-label">Status</span>
19
+ <span class="detail-value">
20
+ <span class="badge badge-<%= @notification.delivery_state %>"><%= @notification.delivery_state.humanize %></span>
21
+ </span>
22
+ </div>
23
+ <div class="detail-row">
24
+ <span class="detail-label">Recipient</span>
25
+ <span class="detail-value"><%= @notification.recipient %></span>
26
+ </div>
27
+ <div class="detail-row">
28
+ <span class="detail-label">Type</span>
29
+ <span class="detail-value"><span class="badge"><%= @notification.notification_type %></span></span>
30
+ </div>
31
+ <div class="detail-row">
32
+ <span class="detail-label">Template</span>
33
+ <span class="detail-value">
34
+ <% if @notification.template %>
35
+ <a href="<%= sincerely.template_path(@notification.template) %>"><%= @notification.template.name %></a>
36
+ <% else %>
37
+ N/A
38
+ <% end %>
39
+ </span>
40
+ </div>
41
+ <div class="detail-row">
42
+ <span class="detail-label">Message ID</span>
43
+ <span class="detail-value"><code><%= @notification.message_id || '-' %></code></span>
44
+ </div>
45
+ <div class="detail-row">
46
+ <span class="detail-label">Delivery System</span>
47
+ <span class="detail-value"><%= @notification.delivery_system || '-' %></span>
48
+ </div>
49
+ <div class="detail-row">
50
+ <span class="detail-label">Created At</span>
51
+ <span class="detail-value"><%= @notification.created_at.strftime('%b %d, %Y %H:%M:%S') %></span>
52
+ </div>
53
+ <div class="detail-row">
54
+ <span class="detail-label">Sent At</span>
55
+ <span class="detail-value"><%= @notification.sent_at&.strftime('%b %d, %Y %H:%M:%S') || '-' %></span>
56
+ </div>
57
+ </div>
58
+ </div>
59
+
60
+ <div class="card">
61
+ <div class="card-header">
62
+ <h2 class="card-title">Delivery Options</h2>
63
+ </div>
64
+ <% if @notification.delivery_options.present? %>
65
+ <div class="code-block">
66
+ <pre><%= JSON.pretty_generate(@notification.delivery_options) %></pre>
67
+ </div>
68
+ <% else %>
69
+ <p class="empty-state">No delivery options</p>
70
+ <% end %>
71
+ </div>
72
+ </div>
73
+
74
+ <div class="card">
75
+ <div class="card-header">
76
+ <h2 class="card-title">Event Timeline</h2>
77
+ </div>
78
+ <% if @timeline.any? %>
79
+ <div class="timeline">
80
+ <% @timeline.each do |event| %>
81
+ <div class="timeline-item">
82
+ <div class="timeline-marker badge-<%= event[:type] %>"></div>
83
+ <div class="timeline-content">
84
+ <div class="timeline-header">
85
+ <span class="badge badge-<%= event[:type] %>"><%= event[:type] %></span>
86
+ <span class="timeline-time"><%= event[:timestamp].strftime('%b %d, %Y %H:%M:%S') %></span>
87
+ </div>
88
+ <% if event[:details].present? %>
89
+ <div class="timeline-details"><%= event[:details] %></div>
90
+ <% end %>
91
+ </div>
92
+ </div>
93
+ <% end %>
94
+ </div>
95
+ <% else %>
96
+ <p class="empty-state">No events recorded yet</p>
97
+ <% end %>
98
+ </div>
@@ -0,0 +1,592 @@
1
+ <div class="card">
2
+ <div class="card-header">
3
+ <h2 class="card-title">Send Email</h2>
4
+ <span class="recipients-limit">Max 50 recipients</span>
5
+ </div>
6
+
7
+ <div class="send-form">
8
+ <div class="form-group">
9
+ <label class="form-label">Recipients</label>
10
+ <div class="tags-input-container" id="tagsContainer">
11
+ <div class="tags-list" id="tagsList"></div>
12
+ <input
13
+ type="text"
14
+ class="tags-input"
15
+ id="recipientInput"
16
+ placeholder="Enter email addresses..."
17
+ autocomplete="off"
18
+ >
19
+ </div>
20
+ <span class="form-hint">Type and press Enter, or paste multiple emails separated by comma or space</span>
21
+ <div class="recipients-count" id="recipientsCount">0 / 50 recipients</div>
22
+ </div>
23
+
24
+ <div class="form-group">
25
+ <label class="form-label">Template</label>
26
+ <select class="form-control" id="templateSelect">
27
+ <option value="">Select a template...</option>
28
+ <% @templates.each do |template| %>
29
+ <option value="<%= template.id %>"><%= template.name %></option>
30
+ <% end %>
31
+ </select>
32
+ </div>
33
+
34
+ <div class="template-variables" id="templateVariables" style="display: none;">
35
+ <div class="form-group">
36
+ <label class="form-label">Template Variables</label>
37
+ <div class="variables-grid" id="variablesGrid"></div>
38
+ </div>
39
+ </div>
40
+
41
+ <div class="form-actions">
42
+ <button type="button" class="btn btn-primary" id="sendButton" disabled>
43
+ <span class="btn-text">Send Emails</span>
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>
48
+ Sending...
49
+ </span>
50
+ </button>
51
+ </div>
52
+ </div>
53
+
54
+ <div class="send-overlay" id="sendOverlay">
55
+ <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>
59
+ <span class="progress-text">Sending emails...</span>
60
+ </div>
61
+ </div>
62
+
63
+ <div class="send-result" id="sendResult" style="display: none;">
64
+ <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>
68
+ </div>
69
+ <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>
74
+ </div>
75
+ <div class="result-message" id="resultMessage"></div>
76
+ <button type="button" class="btn btn-secondary" id="resetButton">Send More</button>
77
+ </div>
78
+ </div>
79
+
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
+
89
+ .send-form {
90
+ position: relative;
91
+ }
92
+
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
+ }
105
+
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
+ }
110
+
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,19 @@
1
+ <% if pagination[:total_pages] > 1 %>
2
+ <div class="pagination">
3
+ <% if pagination[:current_page] > 1 %>
4
+ <a href="<%= url_for(request.query_parameters.merge(page: pagination[:current_page] - 1)) %>" class="pagination-link">&laquo; Previous</a>
5
+ <% else %>
6
+ <span class="pagination-link disabled">&laquo; Previous</span>
7
+ <% end %>
8
+
9
+ <span class="pagination-info">
10
+ Page <%= pagination[:current_page] %> of <%= pagination[:total_pages] %>
11
+ </span>
12
+
13
+ <% if pagination[:current_page] < pagination[:total_pages] %>
14
+ <a href="<%= url_for(request.query_parameters.merge(page: pagination[:current_page] + 1)) %>" class="pagination-link">Next &raquo;</a>
15
+ <% else %>
16
+ <span class="pagination-link disabled">Next &raquo;</span>
17
+ <% end %>
18
+ </div>
19
+ <% end %>