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,382 @@
1
+ <div class="page-header">
2
+ <h1 class="page-title">Dashboard</h1>
3
+ </div>
4
+
5
+ <div class="stats-grid">
6
+ <div class="stat-card">
7
+ <div class="stat-label">Total Notifications</div>
8
+ <div class="stat-value primary"><%= number_with_delimiter(@total_notifications) %></div>
9
+ </div>
10
+ <div class="stat-card">
11
+ <div class="stat-label">Delivery Rate</div>
12
+ <div class="stat-value success"><%= @delivery_rate %>%</div>
13
+ </div>
14
+ <div class="stat-card">
15
+ <div class="stat-label">Open Rate</div>
16
+ <div class="stat-value warning"><%= @open_rate %>%</div>
17
+ </div>
18
+ <div class="stat-card">
19
+ <div class="stat-label">Bounce Rate</div>
20
+ <div class="stat-value danger"><%= @bounce_rate %>%</div>
21
+ </div>
22
+ </div>
23
+
24
+ <%# Line Chart - Notifications Over Time %>
25
+ <div class="card">
26
+ <div class="card-header">
27
+ <h2 class="card-title">Notifications Over Time</h2>
28
+ </div>
29
+ <% if @notifications_timeline[:series].any? %>
30
+ <%
31
+ state_colors = {
32
+ 'draft' => '#9ca3af',
33
+ 'accepted' => '#B9E0B8',
34
+ 'delivered' => '#10b981',
35
+ 'opened' => '#78c6cc',
36
+ 'clicked' => '#BDD9DB',
37
+ 'bounced' => '#D4183D',
38
+ 'rejected' => '#D4183D',
39
+ 'complained' => '#D4183D',
40
+ 'delayed' => '#f97316'
41
+ }
42
+
43
+ labels = @notifications_timeline[:labels]
44
+ series = @notifications_timeline[:series]
45
+
46
+ # Chart dimensions
47
+ chart_width = 800
48
+ chart_height = 220
49
+ padding_left = 45
50
+ padding_right = 20
51
+ padding_top = 20
52
+ padding_bottom = 30
53
+
54
+ plot_width = chart_width - padding_left - padding_right
55
+ plot_height = chart_height - padding_top - padding_bottom
56
+
57
+ max_value = series.values.flatten.max || 0
58
+ max_value = [max_value, 1].max # Minimum of 1 to avoid division by zero
59
+
60
+ magnitude = 10 ** Math.log10([max_value, 1].max).floor
61
+ max_y = ((max_value.to_f / magnitude).ceil * magnitude).to_i
62
+ max_y = [max_y, 1].max
63
+
64
+ x_step = labels.length > 1 ? plot_width.to_f / (labels.length - 1) : plot_width
65
+ %>
66
+ <div class="line-chart-container" id="lineChart">
67
+ <div class="line-chart-wrapper">
68
+ <svg class="line-chart" viewBox="0 0 <%= chart_width %> <%= chart_height %>" preserveAspectRatio="xMidYMid meet">
69
+ <%# Grid lines %>
70
+ <% 5.times do |i| %>
71
+ <% y = padding_top + (plot_height * i / 4.0) %>
72
+ <line class="line-chart-grid" x1="<%= padding_left %>" y1="<%= y %>" x2="<%= chart_width - padding_right %>" y2="<%= y %>" />
73
+ <text class="line-chart-label line-chart-label-y" x="<%= padding_left - 8 %>" y="<%= y + 3 %>"><%= (max_y * (4 - i) / 4.0).round %></text>
74
+ <% end %>
75
+
76
+ <%# X-axis labels (show subset to avoid overlap) %>
77
+ <% label_step = [1, (labels.length / 8.0).ceil].max %>
78
+ <% labels.each_with_index do |label, i| %>
79
+ <% if i % label_step == 0 || i == labels.length - 1 %>
80
+ <% x = padding_left + (i * x_step) %>
81
+ <text class="line-chart-label line-chart-label-x" x="<%= x %>" y="<%= chart_height - 8 %>"><%= label %></text>
82
+ <% end %>
83
+ <% end %>
84
+
85
+ <%# Lines for each state %>
86
+ <% series.each do |state, values| %>
87
+ <% color = state_colors[state.to_s] || '#6b7280' %>
88
+ <%
89
+ points = values.each_with_index.map do |value, i|
90
+ x = padding_left + (i * x_step)
91
+ y = padding_top + plot_height - (value.to_f / max_y * plot_height)
92
+ "#{x.round(2)},#{y.round(2)}"
93
+ end.join(' ')
94
+
95
+ # Area fill points
96
+ area_points = "#{padding_left},#{padding_top + plot_height} #{points} #{padding_left + ((values.length - 1) * x_step)},#{padding_top + plot_height}"
97
+ %>
98
+ <polygon class="line-chart-area" data-state="<%= state %>" fill="<%= color %>" points="<%= area_points %>" />
99
+ <polyline class="line-chart-line" data-state="<%= state %>" stroke="<%= color %>" points="<%= points %>" />
100
+ <% end %>
101
+ </svg>
102
+ </div>
103
+
104
+ <%# Legend %>
105
+ <div class="line-chart-legend">
106
+ <% series.each do |state, values| %>
107
+ <% color = state_colors[state.to_s] || '#6b7280' %>
108
+ <div class="line-chart-legend-item" data-state="<%= state %>">
109
+ <span class="donut-legend-color" style="background: <%= color %>"></span>
110
+ <span class="donut-legend-text"><%= state.to_s.humanize %></span>
111
+ </div>
112
+ <% end %>
113
+ </div>
114
+ </div>
115
+
116
+ <script>
117
+ (function() {
118
+ const chart = document.getElementById('lineChart');
119
+ if (!chart) return;
120
+
121
+ const lines = chart.querySelectorAll('.line-chart-line');
122
+ const areas = chart.querySelectorAll('.line-chart-area');
123
+ const legendItems = chart.querySelectorAll('.line-chart-legend-item');
124
+
125
+ function highlightState(state) {
126
+ lines.forEach(line => {
127
+ if (line.dataset.state === state) {
128
+ line.classList.add('highlighted');
129
+ line.classList.remove('dimmed');
130
+ } else {
131
+ line.classList.add('dimmed');
132
+ line.classList.remove('highlighted');
133
+ }
134
+ });
135
+ areas.forEach(area => {
136
+ if (area.dataset.state === state) {
137
+ area.classList.add('highlighted');
138
+ area.classList.remove('dimmed');
139
+ } else {
140
+ area.classList.add('dimmed');
141
+ area.classList.remove('highlighted');
142
+ }
143
+ });
144
+ legendItems.forEach(item => {
145
+ if (item.dataset.state === state) {
146
+ item.classList.add('highlighted');
147
+ item.classList.remove('dimmed');
148
+ } else {
149
+ item.classList.add('dimmed');
150
+ item.classList.remove('highlighted');
151
+ }
152
+ });
153
+ }
154
+
155
+ function resetHighlight() {
156
+ lines.forEach(line => line.classList.remove('highlighted', 'dimmed'));
157
+ areas.forEach(area => area.classList.remove('highlighted', 'dimmed'));
158
+ legendItems.forEach(item => item.classList.remove('highlighted', 'dimmed'));
159
+ }
160
+
161
+ legendItems.forEach(item => {
162
+ item.addEventListener('mouseenter', () => highlightState(item.dataset.state));
163
+ item.addEventListener('mouseleave', resetHighlight);
164
+ });
165
+
166
+ lines.forEach(line => {
167
+ line.addEventListener('mouseenter', () => highlightState(line.dataset.state));
168
+ line.addEventListener('mouseleave', resetHighlight);
169
+ });
170
+
171
+ areas.forEach(area => {
172
+ area.addEventListener('mouseenter', () => highlightState(area.dataset.state));
173
+ area.addEventListener('mouseleave', resetHighlight);
174
+ });
175
+ })();
176
+ </script>
177
+ <% else %>
178
+ <div class="line-chart-empty">No notifications in this period</div>
179
+ <% end %>
180
+ </div>
181
+
182
+ <div class="grid grid-2">
183
+ <div class="card">
184
+ <div class="card-header">
185
+ <h2 class="card-title">Notifications by Status</h2>
186
+ </div>
187
+ <% if @notifications_by_state.any? %>
188
+ <%
189
+ state_colors = {
190
+ 'draft' => '#9ca3af',
191
+ 'accepted' => '#B9E0B8',
192
+ 'delivered' => '#10b981',
193
+ 'opened' => '#78c6cc',
194
+ 'clicked' => '#BDD9DB',
195
+ 'bounced' => '#D4183D',
196
+ 'rejected' => '#D4183D',
197
+ 'complained' => '#D4183D',
198
+ 'delayed' => '#f97316'
199
+ }
200
+ total = @notifications_by_state.values.sum
201
+ radius = 68
202
+ circumference = 2 * Math::PI * radius
203
+ offset = 0
204
+ %>
205
+ <div class="donut-chart-container" id="donutChart">
206
+ <div class="donut-chart-wrapper">
207
+ <svg class="donut-chart" viewBox="0 0 200 200">
208
+ <% @notifications_by_state.each do |state, count| %>
209
+ <%
210
+ percentage = count.to_f / total
211
+ dash_length = circumference * percentage
212
+ # Add small overlap to prevent visual gaps between segments
213
+ overlap = 0.5
214
+ visible_dash = dash_length + overlap
215
+ color = state_colors[state.to_s] || '#6b7280'
216
+ %>
217
+ <circle
218
+ class="donut-segment"
219
+ data-state="<%= state %>"
220
+ cx="100" cy="100" r="<%= radius %>"
221
+ stroke="<%= color %>"
222
+ stroke-dasharray="<%= visible_dash %> <%= circumference - visible_dash %>"
223
+ stroke-dashoffset="<%= -offset %>"
224
+ />
225
+ <% offset += dash_length %>
226
+ <% end %>
227
+ </svg>
228
+ <div class="donut-center">
229
+ <div class="donut-center-value"><%= number_with_delimiter(total) %></div>
230
+ <div class="donut-center-label">Total</div>
231
+ </div>
232
+ </div>
233
+ <div class="donut-legend">
234
+ <% @notifications_by_state.each do |state, count| %>
235
+ <% color = state_colors[state.to_s] || '#6b7280' %>
236
+ <div class="donut-legend-item" data-state="<%= state %>">
237
+ <span class="donut-legend-color" style="background: <%= color %>"></span>
238
+ <span class="donut-legend-text"><%= state.to_s.humanize %></span>
239
+ <span class="donut-legend-count"><%= number_with_delimiter(count) %></span>
240
+ </div>
241
+ <% end %>
242
+ </div>
243
+ </div>
244
+ <script>
245
+ (function() {
246
+ const chart = document.getElementById('donutChart');
247
+ if (!chart) return;
248
+
249
+ const segments = chart.querySelectorAll('.donut-segment');
250
+ const legendItems = chart.querySelectorAll('.donut-legend-item');
251
+
252
+ function highlightState(state) {
253
+ segments.forEach(seg => {
254
+ if (seg.dataset.state === state) {
255
+ seg.classList.add('highlighted');
256
+ seg.classList.remove('dimmed');
257
+ } else {
258
+ seg.classList.add('dimmed');
259
+ seg.classList.remove('highlighted');
260
+ }
261
+ });
262
+ legendItems.forEach(item => {
263
+ if (item.dataset.state === state) {
264
+ item.classList.add('highlighted');
265
+ item.classList.remove('dimmed');
266
+ } else {
267
+ item.classList.add('dimmed');
268
+ item.classList.remove('highlighted');
269
+ }
270
+ });
271
+ }
272
+
273
+ function resetHighlight() {
274
+ segments.forEach(seg => {
275
+ seg.classList.remove('highlighted', 'dimmed');
276
+ });
277
+ legendItems.forEach(item => {
278
+ item.classList.remove('highlighted', 'dimmed');
279
+ });
280
+ }
281
+
282
+ legendItems.forEach(item => {
283
+ item.addEventListener('mouseenter', () => highlightState(item.dataset.state));
284
+ item.addEventListener('mouseleave', resetHighlight);
285
+ });
286
+
287
+ segments.forEach(seg => {
288
+ seg.addEventListener('mouseenter', () => highlightState(seg.dataset.state));
289
+ seg.addEventListener('mouseleave', resetHighlight);
290
+ });
291
+ })();
292
+ </script>
293
+ <% else %>
294
+ <div class="donut-empty">No notifications in this period</div>
295
+ <% end %>
296
+ </div>
297
+
298
+ <div class="card">
299
+ <div class="card-header">
300
+ <h2 class="card-title">Recent Notifications</h2>
301
+ <a href="<%= sincerely.notifications_path %>" class="btn btn-secondary btn-sm">View All</a>
302
+ </div>
303
+ <div class="table-wrapper">
304
+ <table>
305
+ <thead>
306
+ <tr><th>Recipient</th><th>Status</th><th>Date</th></tr>
307
+ </thead>
308
+ <tbody>
309
+ <% if @recent_notifications.any? %>
310
+ <% @recent_notifications.each do |notification| %>
311
+ <tr class="clickable-row" onclick="window.location='<%= sincerely.notification_path(notification) %>'">
312
+ <td><%= truncate(notification.recipient, length: 30) %></td>
313
+ <td><span class="badge badge-<%= notification.delivery_state %>"><%= notification.delivery_state.humanize %></span></td>
314
+ <td><%= notification.created_at.strftime('%b %d, %H:%M') %></td>
315
+ </tr>
316
+ <% end %>
317
+ <% else %>
318
+ <tr><td colspan="3" class="empty-state">No recent notifications</td></tr>
319
+ <% end %>
320
+ </tbody>
321
+ </table>
322
+ </div>
323
+ </div>
324
+ </div>
325
+
326
+ <div class="grid grid-2">
327
+ <div class="card">
328
+ <div class="card-header">
329
+ <h2 class="card-title">Recent Delivery Events</h2>
330
+ <a href="<%= sincerely.delivery_events_path %>" class="btn btn-secondary btn-sm">View All</a>
331
+ </div>
332
+ <div class="table-wrapper">
333
+ <table>
334
+ <thead>
335
+ <tr><th>Type</th><th>Recipient</th><th>Time</th></tr>
336
+ </thead>
337
+ <tbody>
338
+ <% if @recent_delivery_events.any? %>
339
+ <% @recent_delivery_events.each do |event| %>
340
+ <% notification = notification_model.find_by(message_id: event.message_id) %>
341
+ <tr<%= " class=\"clickable-row\" onclick=\"window.location='#{sincerely.notification_path(notification)}'\"".html_safe if notification %>>
342
+ <td><span class="badge badge-<%= event.event_type %>"><%= event.event_type %></span></td>
343
+ <td><%= truncate(event.recipient, length: 25) %></td>
344
+ <td><%= (event.timestamp || event.created_at).strftime('%b %d, %H:%M') %></td>
345
+ </tr>
346
+ <% end %>
347
+ <% else %>
348
+ <tr><td colspan="3" class="empty-state">No delivery events</td></tr>
349
+ <% end %>
350
+ </tbody>
351
+ </table>
352
+ </div>
353
+ </div>
354
+
355
+ <div class="card">
356
+ <div class="card-header">
357
+ <h2 class="card-title">Recent Engagement Events</h2>
358
+ <a href="<%= sincerely.engagement_events_path %>" class="btn btn-secondary btn-sm">View All</a>
359
+ </div>
360
+ <div class="table-wrapper">
361
+ <table>
362
+ <thead>
363
+ <tr><th>Type</th><th>Recipient</th><th>Time</th></tr>
364
+ </thead>
365
+ <tbody>
366
+ <% if @recent_engagement_events.any? %>
367
+ <% @recent_engagement_events.each do |event| %>
368
+ <% notification = notification_model.find_by(message_id: event.message_id) %>
369
+ <tr<%= " class=\"clickable-row\" onclick=\"window.location='#{sincerely.notification_path(notification)}'\"".html_safe if notification %>>
370
+ <td><span class="badge badge-<%= event.event_type %>"><%= event.event_type %></span></td>
371
+ <td><%= truncate(event.recipient, length: 25) %></td>
372
+ <td><%= (event.timestamp || event.created_at).strftime('%b %d, %H:%M') %></td>
373
+ </tr>
374
+ <% end %>
375
+ <% else %>
376
+ <tr><td colspan="3" class="empty-state">No engagement events</td></tr>
377
+ <% end %>
378
+ </tbody>
379
+ </table>
380
+ </div>
381
+ </div>
382
+ </div>
@@ -0,0 +1,97 @@
1
+ <div class="page-header">
2
+ <h1 class="page-title">Delivery Events</h1>
3
+ <p class="page-subtitle">Email delivery status events from your provider</p>
4
+ </div>
5
+
6
+ <div class="card">
7
+ <div class="card-header">
8
+ <h2 class="card-title">Filters</h2>
9
+ </div>
10
+ <form method="get" action="<%= sincerely.delivery_events_path %>" class="filters-form">
11
+ <div class="filters-grid">
12
+ <div class="filter-group">
13
+ <label for="event_type">Status</label>
14
+ <select name="event_type" id="event_type">
15
+ <option value="">All statuses</option>
16
+ <% @event_types.each do |type| %>
17
+ <option value="<%= type %>" <%= 'selected' if params[:event_type] == type %>><%= type.to_s.humanize %></option>
18
+ <% end %>
19
+ </select>
20
+ </div>
21
+ <div class="filter-group">
22
+ <label for="template_id">Template</label>
23
+ <select name="template_id" id="template_id">
24
+ <option value="">All templates</option>
25
+ <% @templates.each do |template| %>
26
+ <option value="<%= template.id %>" <%= 'selected' if params[:template_id] == template.id.to_s %>>
27
+ <%= template.name %>
28
+ </option>
29
+ <% end %>
30
+ </select>
31
+ </div>
32
+ <div class="filter-group">
33
+ <label for="recipient">Recipient</label>
34
+ <input type="text" name="recipient" id="recipient" value="<%= params[:recipient] %>" placeholder="Search by recipient...">
35
+ </div>
36
+ <div class="filter-group">
37
+ <label for="notification_type">Type</label>
38
+ <select name="notification_type" id="notification_type">
39
+ <option value="">All types</option>
40
+ <option value="email" <%= 'selected' if params[:notification_type] == 'email' %>>Email</option>
41
+ <option value="sms" <%= 'selected' if params[:notification_type] == 'sms' %>>SMS</option>
42
+ <option value="push" <%= 'selected' if params[:notification_type] == 'push' %>>Push</option>
43
+ </select>
44
+ </div>
45
+ </div>
46
+ <div class="filter-actions">
47
+ <button type="submit" class="btn btn-primary">Apply Filters</button>
48
+ <a href="<%= sincerely.delivery_events_path %>" class="btn btn-secondary">Clear</a>
49
+ </div>
50
+ </form>
51
+ </div>
52
+
53
+ <div class="card">
54
+ <div class="card-header">
55
+ <h2 class="card-title">Events (<%= @pagination[:total_count] %>)</h2>
56
+ </div>
57
+ <div class="table-wrapper">
58
+ <table>
59
+ <thead>
60
+ <tr>
61
+ <th>Type</th>
62
+ <th>Recipient</th>
63
+ <th>Message ID</th>
64
+ <th>Details</th>
65
+ <th>Timestamp</th>
66
+ </tr>
67
+ </thead>
68
+ <tbody>
69
+ <% @events.each do |event| %>
70
+ <% notification = notification_model.find_by(message_id: event.message_id) %>
71
+ <tr<%= " class=\"clickable-row\" onclick=\"window.location='#{sincerely.notification_path(notification)}'\"".html_safe if notification %>>
72
+ <td><span class="badge badge-<%= event.event_type %>"><%= event.event_type %></span></td>
73
+ <td><%= truncate(event.recipient, length: 30) %></td>
74
+ <td><code><%= truncate(event.message_id, length: 20) %></code></td>
75
+ <td>
76
+ <% if event.options.present? %>
77
+ <details onclick="event.stopPropagation()">
78
+ <summary class="btn btn-secondary btn-sm">View</summary>
79
+ <pre class="event-details"><%= JSON.pretty_generate(event.options) rescue event.options %></pre>
80
+ </details>
81
+ <% else %>
82
+ -
83
+ <% end %>
84
+ </td>
85
+ <td><%= (event.timestamp || event.created_at).strftime('%b %d, %Y %H:%M:%S') %></td>
86
+ </tr>
87
+ <% end %>
88
+ <% if @events.empty? %>
89
+ <tr>
90
+ <td colspan="5" class="empty-state">No delivery events found</td>
91
+ </tr>
92
+ <% end %>
93
+ </tbody>
94
+ </table>
95
+ </div>
96
+ <%= render 'sincerely/shared/pagination', pagination: @pagination %>
97
+ </div>
@@ -0,0 +1,97 @@
1
+ <div class="page-header">
2
+ <h1 class="page-title">Engagement Events</h1>
3
+ <p class="page-subtitle">Email opens, clicks, and other engagement metrics</p>
4
+ </div>
5
+
6
+ <div class="card">
7
+ <div class="card-header">
8
+ <h2 class="card-title">Filters</h2>
9
+ </div>
10
+ <form method="get" action="<%= sincerely.engagement_events_path %>" class="filters-form">
11
+ <div class="filters-grid">
12
+ <div class="filter-group">
13
+ <label for="event_type">Status</label>
14
+ <select name="event_type" id="event_type">
15
+ <option value="">All statuses</option>
16
+ <% @event_types.each do |type| %>
17
+ <option value="<%= type %>" <%= 'selected' if params[:event_type] == type %>><%= type.to_s.humanize %></option>
18
+ <% end %>
19
+ </select>
20
+ </div>
21
+ <div class="filter-group">
22
+ <label for="template_id">Template</label>
23
+ <select name="template_id" id="template_id">
24
+ <option value="">All templates</option>
25
+ <% @templates.each do |template| %>
26
+ <option value="<%= template.id %>" <%= 'selected' if params[:template_id] == template.id.to_s %>>
27
+ <%= template.name %>
28
+ </option>
29
+ <% end %>
30
+ </select>
31
+ </div>
32
+ <div class="filter-group">
33
+ <label for="recipient">Recipient</label>
34
+ <input type="text" name="recipient" id="recipient" value="<%= params[:recipient] %>" placeholder="Search by recipient...">
35
+ </div>
36
+ <div class="filter-group">
37
+ <label for="notification_type">Type</label>
38
+ <select name="notification_type" id="notification_type">
39
+ <option value="">All types</option>
40
+ <option value="email" <%= 'selected' if params[:notification_type] == 'email' %>>Email</option>
41
+ <option value="sms" <%= 'selected' if params[:notification_type] == 'sms' %>>SMS</option>
42
+ <option value="push" <%= 'selected' if params[:notification_type] == 'push' %>>Push</option>
43
+ </select>
44
+ </div>
45
+ </div>
46
+ <div class="filter-actions">
47
+ <button type="submit" class="btn btn-primary">Apply Filters</button>
48
+ <a href="<%= sincerely.engagement_events_path %>" class="btn btn-secondary">Clear</a>
49
+ </div>
50
+ </form>
51
+ </div>
52
+
53
+ <div class="card">
54
+ <div class="card-header">
55
+ <h2 class="card-title">Events (<%= @pagination[:total_count] %>)</h2>
56
+ </div>
57
+ <div class="table-wrapper">
58
+ <table>
59
+ <thead>
60
+ <tr>
61
+ <th>Type</th>
62
+ <th>Recipient</th>
63
+ <th>Message ID</th>
64
+ <th>Details</th>
65
+ <th>Timestamp</th>
66
+ </tr>
67
+ </thead>
68
+ <tbody>
69
+ <% @events.each do |event| %>
70
+ <% notification = notification_model.find_by(message_id: event.message_id) %>
71
+ <tr<%= " class=\"clickable-row\" onclick=\"window.location='#{sincerely.notification_path(notification)}'\"".html_safe if notification %>>
72
+ <td><span class="badge badge-<%= event.event_type %>"><%= event.event_type %></span></td>
73
+ <td><%= truncate(event.recipient, length: 30) %></td>
74
+ <td><code><%= truncate(event.message_id, length: 20) %></code></td>
75
+ <td>
76
+ <% if event.options.present? %>
77
+ <details onclick="event.stopPropagation()">
78
+ <summary class="btn btn-secondary btn-sm">View</summary>
79
+ <pre class="event-details"><%= JSON.pretty_generate(event.options) rescue event.options %></pre>
80
+ </details>
81
+ <% else %>
82
+ -
83
+ <% end %>
84
+ </td>
85
+ <td><%= (event.timestamp || event.created_at).strftime('%b %d, %Y %H:%M:%S') %></td>
86
+ </tr>
87
+ <% end %>
88
+ <% if @events.empty? %>
89
+ <tr>
90
+ <td colspan="5" class="empty-state">No engagement events found</td>
91
+ </tr>
92
+ <% end %>
93
+ </tbody>
94
+ </table>
95
+ </div>
96
+ <%= render 'sincerely/shared/pagination', pagination: @pagination %>
97
+ </div>
@@ -0,0 +1,91 @@
1
+ <div class="page-header">
2
+ <h1 class="page-title">Notifications</h1>
3
+ <p class="page-subtitle">All notifications sent through the system</p>
4
+ </div>
5
+
6
+ <div class="card">
7
+ <div class="card-header">
8
+ <h2 class="card-title">Filter Notifications</h2>
9
+ </div>
10
+ <form method="get" action="<%= sincerely.notifications_path %>" class="filters-form">
11
+ <div class="filters-grid">
12
+ <div class="filter-group">
13
+ <label for="status">Status</label>
14
+ <select name="status" id="status">
15
+ <option value="">All statuses</option>
16
+ <% @states.each do |state| %>
17
+ <option value="<%= state %>" <%= 'selected' if params[:status] == state %>>
18
+ <%= state.to_s.humanize %>
19
+ </option>
20
+ <% end %>
21
+ </select>
22
+ </div>
23
+ <div class="filter-group">
24
+ <label for="template_id">Template</label>
25
+ <select name="template_id" id="template_id">
26
+ <option value="">All templates</option>
27
+ <% @templates.each do |template| %>
28
+ <option value="<%= template.id %>" <%= 'selected' if params[:template_id] == template.id.to_s %>>
29
+ <%= template.name %>
30
+ </option>
31
+ <% end %>
32
+ </select>
33
+ </div>
34
+ <div class="filter-group">
35
+ <label for="recipient">Recipient</label>
36
+ <input type="text" name="recipient" id="recipient" value="<%= params[:recipient] %>" placeholder="Search by recipient...">
37
+ </div>
38
+ <div class="filter-group">
39
+ <label for="notification_type">Type</label>
40
+ <select name="notification_type" id="notification_type">
41
+ <option value="">All types</option>
42
+ <option value="email" <%= 'selected' if params[:notification_type] == 'email' %>>Email</option>
43
+ <option value="sms" <%= 'selected' if params[:notification_type] == 'sms' %>>SMS</option>
44
+ <option value="push" <%= 'selected' if params[:notification_type] == 'push' %>>Push</option>
45
+ </select>
46
+ </div>
47
+ </div>
48
+ <div class="filter-actions">
49
+ <button type="submit" class="btn btn-primary">Apply Filters</button>
50
+ <a href="<%= sincerely.notifications_path %>" class="btn btn-secondary">Clear</a>
51
+ </div>
52
+ </form>
53
+ </div>
54
+
55
+ <div class="card">
56
+ <div class="card-header">
57
+ <h2 class="card-title">Results (<%= @pagination[:total_count] %>)</h2>
58
+ </div>
59
+ <div class="table-wrapper">
60
+ <table>
61
+ <thead>
62
+ <tr>
63
+ <th>ID</th>
64
+ <th>Recipient</th>
65
+ <th>Template</th>
66
+ <th>Type</th>
67
+ <th>Status</th>
68
+ <th>Sent At</th>
69
+ </tr>
70
+ </thead>
71
+ <tbody>
72
+ <% @notifications.each do |notification| %>
73
+ <tr class="clickable-row" onclick="window.location='<%= sincerely.notification_path(notification) %>'">
74
+ <td><%= notification.id %></td>
75
+ <td><%= truncate(notification.recipient, length: 35) %></td>
76
+ <td><%= notification.template&.name || 'N/A' %></td>
77
+ <td><span class="badge"><%= notification.notification_type %></span></td>
78
+ <td><span class="badge badge-<%= notification.delivery_state %>"><%= notification.delivery_state.humanize %></span></td>
79
+ <td><%= notification.sent_at&.strftime('%b %d, %Y %H:%M') || '-' %></td>
80
+ </tr>
81
+ <% end %>
82
+ <% if @notifications.empty? %>
83
+ <tr>
84
+ <td colspan="6" class="empty-state">No notifications found</td>
85
+ </tr>
86
+ <% end %>
87
+ </tbody>
88
+ </table>
89
+ </div>
90
+ <%= render 'sincerely/shared/pagination', pagination: @pagination %>
91
+ </div>