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
@@ -27,153 +27,9 @@
27
27
  <h2 class="card-title">Notifications Over Time</h2>
28
28
  </div>
29
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>
30
+ <%= render 'sincerely/shared/line_chart',
31
+ labels: @notifications_timeline[:labels],
32
+ series: @notifications_timeline[:series] %>
177
33
  <% else %>
178
34
  <div class="line-chart-empty">No notifications in this period</div>
179
35
  <% end %>
@@ -185,111 +41,36 @@
185
41
  <h2 class="card-title">Notifications by Status</h2>
186
42
  </div>
187
43
  <% 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
- %>
44
+ <% segments = donut_segment_data(@notifications_by_state) %>
205
45
  <div class="donut-chart-container" id="donutChart">
206
46
  <div class="donut-chart-wrapper">
207
47
  <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
- %>
48
+ <% segments.each do |seg| %>
217
49
  <circle
218
50
  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 %>"
51
+ data-state="<%= seg[:state] %>"
52
+ cx="100" cy="100" r="<%= Sincerely::ChartHelper::DONUT_RADIUS %>"
53
+ stroke="<%= seg[:color] %>"
54
+ stroke-dasharray="<%= seg[:visible_dash] %> <%= seg[:gap] %>"
55
+ stroke-dashoffset="<%= seg[:offset] %>"
224
56
  />
225
- <% offset += dash_length %>
226
57
  <% end %>
227
58
  </svg>
228
59
  <div class="donut-center">
229
- <div class="donut-center-value"><%= number_with_delimiter(total) %></div>
60
+ <div class="donut-center-value"><%= number_with_delimiter(@notifications_by_state.values.sum) %></div>
230
61
  <div class="donut-center-label">Total</div>
231
62
  </div>
232
63
  </div>
233
64
  <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>
65
+ <% segments.each do |seg| %>
66
+ <div class="donut-legend-item" data-state="<%= seg[:state] %>">
67
+ <span class="donut-legend-color" style="background: <%= seg[:color] %>"></span>
68
+ <span class="donut-legend-text"><%= seg[:state].to_s.humanize %></span>
69
+ <span class="donut-legend-count"><%= number_with_delimiter(seg[:count]) %></span>
240
70
  </div>
241
71
  <% end %>
242
72
  </div>
243
73
  </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
74
  <% else %>
294
75
  <div class="donut-empty">No notifications in this period</div>
295
76
  <% end %>
@@ -298,7 +79,7 @@
298
79
  <div class="card">
299
80
  <div class="card-header">
300
81
  <h2 class="card-title">Recent Notifications</h2>
301
- <a href="<%= sincerely.notifications_path %>" class="btn btn-secondary btn-sm">View All</a>
82
+ <a href="<%= notifications_path %>" class="btn btn-secondary btn-sm">View All</a>
302
83
  </div>
303
84
  <div class="table-wrapper">
304
85
  <table>
@@ -308,9 +89,9 @@
308
89
  <tbody>
309
90
  <% if @recent_notifications.any? %>
310
91
  <% @recent_notifications.each do |notification| %>
311
- <tr class="clickable-row" onclick="window.location='<%= sincerely.notification_path(notification) %>'">
92
+ <tr class="clickable-row" role="link" tabindex="0" data-href="<%= notification_path(notification) %>">
312
93
  <td><%= truncate(notification.recipient, length: 30) %></td>
313
- <td><span class="badge badge-<%= notification.delivery_state %>"><%= notification.delivery_state.humanize %></span></td>
94
+ <td><span class="badge <%= state_badge_class(notification.delivery_state) %>"><%= notification.delivery_state.humanize %></span></td>
314
95
  <td><%= notification.created_at.strftime('%b %d, %H:%M') %></td>
315
96
  </tr>
316
97
  <% end %>
@@ -327,7 +108,7 @@
327
108
  <div class="card">
328
109
  <div class="card-header">
329
110
  <h2 class="card-title">Recent Delivery Events</h2>
330
- <a href="<%= sincerely.delivery_events_path %>" class="btn btn-secondary btn-sm">View All</a>
111
+ <a href="<%= delivery_events_path %>" class="btn btn-secondary btn-sm">View All</a>
331
112
  </div>
332
113
  <div class="table-wrapper">
333
114
  <table>
@@ -336,14 +117,7 @@
336
117
  </thead>
337
118
  <tbody>
338
119
  <% 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 %>
120
+ <%= render partial: 'sincerely/shared/event', collection: @recent_delivery_events, locals: { notifications_by_message_id: @notifications_by_message_id, detailed: false, recipient_length: 25 } %>
347
121
  <% else %>
348
122
  <tr><td colspan="3" class="empty-state">No delivery events</td></tr>
349
123
  <% end %>
@@ -355,7 +129,7 @@
355
129
  <div class="card">
356
130
  <div class="card-header">
357
131
  <h2 class="card-title">Recent Engagement Events</h2>
358
- <a href="<%= sincerely.engagement_events_path %>" class="btn btn-secondary btn-sm">View All</a>
132
+ <a href="<%= engagement_events_path %>" class="btn btn-secondary btn-sm">View All</a>
359
133
  </div>
360
134
  <div class="table-wrapper">
361
135
  <table>
@@ -364,14 +138,7 @@
364
138
  </thead>
365
139
  <tbody>
366
140
  <% 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 %>
141
+ <%= render partial: 'sincerely/shared/event', collection: @recent_engagement_events, locals: { notifications_by_message_id: @notifications_by_message_id, detailed: false, recipient_length: 25 } %>
375
142
  <% else %>
376
143
  <tr><td colspan="3" class="empty-state">No engagement events</td></tr>
377
144
  <% end %>
@@ -380,3 +147,5 @@
380
147
  </div>
381
148
  </div>
382
149
  </div>
150
+
151
+ <%= javascript_include_tag 'sincerely/charts' %>
@@ -7,45 +7,31 @@
7
7
  <div class="card-header">
8
8
  <h2 class="card-title">Filters</h2>
9
9
  </div>
10
- <form method="get" action="<%= sincerely.delivery_events_path %>" class="filters-form">
10
+ <form method="get" action="<%= delivery_events_path %>" class="filters-form">
11
11
  <div class="filters-grid">
12
12
  <div class="filter-group">
13
13
  <label for="event_type">Status</label>
14
14
  <select name="event_type" id="event_type">
15
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 %>
16
+ <%= render partial: 'sincerely/shared/status_option', collection: @event_types, as: :status_value, locals: { param_name: :event_type } %>
19
17
  </select>
20
18
  </div>
21
19
  <div class="filter-group">
22
20
  <label for="template_id">Template</label>
23
21
  <select name="template_id" id="template_id">
24
22
  <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 %>
23
+ <%= render partial: 'sincerely/shared/template_option', collection: @templates, as: :template %>
30
24
  </select>
31
25
  </div>
32
26
  <div class="filter-group">
33
27
  <label for="recipient">Recipient</label>
34
28
  <input type="text" name="recipient" id="recipient" value="<%= params[:recipient] %>" placeholder="Search by recipient...">
35
29
  </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>
30
+ <%= render 'sincerely/shared/notification_type_filter' %>
45
31
  </div>
46
32
  <div class="filter-actions">
47
33
  <button type="submit" class="btn btn-primary">Apply Filters</button>
48
- <a href="<%= sincerely.delivery_events_path %>" class="btn btn-secondary">Clear</a>
34
+ <a href="<%= delivery_events_path %>" class="btn btn-secondary">Clear</a>
49
35
  </div>
50
36
  </form>
51
37
  </div>
@@ -66,25 +52,7 @@
66
52
  </tr>
67
53
  </thead>
68
54
  <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 %>
55
+ <%= render partial: 'sincerely/shared/event', collection: @events, locals: { notifications_by_message_id: @notifications_by_message_id, detailed: true, recipient_length: 30 } %>
88
56
  <% if @events.empty? %>
89
57
  <tr>
90
58
  <td colspan="5" class="empty-state">No delivery events found</td>
@@ -7,45 +7,31 @@
7
7
  <div class="card-header">
8
8
  <h2 class="card-title">Filters</h2>
9
9
  </div>
10
- <form method="get" action="<%= sincerely.engagement_events_path %>" class="filters-form">
10
+ <form method="get" action="<%= engagement_events_path %>" class="filters-form">
11
11
  <div class="filters-grid">
12
12
  <div class="filter-group">
13
13
  <label for="event_type">Status</label>
14
14
  <select name="event_type" id="event_type">
15
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 %>
16
+ <%= render partial: 'sincerely/shared/status_option', collection: @event_types, as: :status_value, locals: { param_name: :event_type } %>
19
17
  </select>
20
18
  </div>
21
19
  <div class="filter-group">
22
20
  <label for="template_id">Template</label>
23
21
  <select name="template_id" id="template_id">
24
22
  <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 %>
23
+ <%= render partial: 'sincerely/shared/template_option', collection: @templates, as: :template %>
30
24
  </select>
31
25
  </div>
32
26
  <div class="filter-group">
33
27
  <label for="recipient">Recipient</label>
34
28
  <input type="text" name="recipient" id="recipient" value="<%= params[:recipient] %>" placeholder="Search by recipient...">
35
29
  </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>
30
+ <%= render 'sincerely/shared/notification_type_filter' %>
45
31
  </div>
46
32
  <div class="filter-actions">
47
33
  <button type="submit" class="btn btn-primary">Apply Filters</button>
48
- <a href="<%= sincerely.engagement_events_path %>" class="btn btn-secondary">Clear</a>
34
+ <a href="<%= engagement_events_path %>" class="btn btn-secondary">Clear</a>
49
35
  </div>
50
36
  </form>
51
37
  </div>
@@ -66,25 +52,7 @@
66
52
  </tr>
67
53
  </thead>
68
54
  <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 %>
55
+ <%= render partial: 'sincerely/shared/event', collection: @events, locals: { notifications_by_message_id: @notifications_by_message_id, detailed: true, recipient_length: 30 } %>
88
56
  <% if @events.empty? %>
89
57
  <tr>
90
58
  <td colspan="5" class="empty-state">No engagement events found</td>
@@ -7,47 +7,31 @@
7
7
  <div class="card-header">
8
8
  <h2 class="card-title">Filter Notifications</h2>
9
9
  </div>
10
- <form method="get" action="<%= sincerely.notifications_path %>" class="filters-form">
10
+ <form method="get" action="<%= notifications_path %>" class="filters-form">
11
11
  <div class="filters-grid">
12
12
  <div class="filter-group">
13
13
  <label for="status">Status</label>
14
14
  <select name="status" id="status">
15
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 %>
16
+ <%= render partial: 'sincerely/shared/status_option', collection: @states, as: :status_value, locals: { param_name: :status } %>
21
17
  </select>
22
18
  </div>
23
19
  <div class="filter-group">
24
20
  <label for="template_id">Template</label>
25
21
  <select name="template_id" id="template_id">
26
22
  <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 %>
23
+ <%= render partial: 'sincerely/shared/template_option', collection: @templates, as: :template %>
32
24
  </select>
33
25
  </div>
34
26
  <div class="filter-group">
35
27
  <label for="recipient">Recipient</label>
36
28
  <input type="text" name="recipient" id="recipient" value="<%= params[:recipient] %>" placeholder="Search by recipient...">
37
29
  </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>
30
+ <%= render 'sincerely/shared/notification_type_filter' %>
47
31
  </div>
48
32
  <div class="filter-actions">
49
33
  <button type="submit" class="btn btn-primary">Apply Filters</button>
50
- <a href="<%= sincerely.notifications_path %>" class="btn btn-secondary">Clear</a>
34
+ <a href="<%= notifications_path %>" class="btn btn-secondary">Clear</a>
51
35
  </div>
52
36
  </form>
53
37
  </div>
@@ -70,12 +54,12 @@
70
54
  </thead>
71
55
  <tbody>
72
56
  <% @notifications.each do |notification| %>
73
- <tr class="clickable-row" onclick="window.location='<%= sincerely.notification_path(notification) %>'">
57
+ <tr class="clickable-row" role="link" tabindex="0" data-href="<%= notification_path(notification) %>">
74
58
  <td><%= notification.id %></td>
75
59
  <td><%= truncate(notification.recipient, length: 35) %></td>
76
60
  <td><%= notification.template&.name || 'N/A' %></td>
77
61
  <td><span class="badge"><%= notification.notification_type %></span></td>
78
- <td><span class="badge badge-<%= notification.delivery_state %>"><%= notification.delivery_state.humanize %></span></td>
62
+ <td><span class="badge <%= state_badge_class(notification.delivery_state) %>"><%= notification.delivery_state.humanize %></span></td>
79
63
  <td><%= notification.sent_at&.strftime('%b %d, %Y %H:%M') || '-' %></td>
80
64
  </tr>
81
65
  <% end %>
@@ -1,7 +1,7 @@
1
1
  <div class="page-header page-header--visible">
2
2
  <div class="page-header-top">
3
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>
4
+ <%= inline_svg 'sincerely/chevron_left' %>
5
5
  </button>
6
6
  <h1 class="page-title">Notification #<%= @notification.id %></h1>
7
7
  </div>
@@ -32,7 +32,7 @@
32
32
  <span class="detail-label">Template</span>
33
33
  <span class="detail-value">
34
34
  <% if @notification.template %>
35
- <a href="<%= sincerely.template_path(@notification.template) %>"><%= @notification.template.name %></a>
35
+ <a href="<%= template_path(@notification.template) %>"><%= @notification.template.name %></a>
36
36
  <% else %>
37
37
  N/A
38
38
  <% end %>
@@ -79,10 +79,10 @@
79
79
  <div class="timeline">
80
80
  <% @timeline.each do |event| %>
81
81
  <div class="timeline-item">
82
- <div class="timeline-marker badge-<%= event[:type] %>"></div>
82
+ <div class="timeline-marker <%= event_type_badge_class(event[:type]) %>"></div>
83
83
  <div class="timeline-content">
84
84
  <div class="timeline-header">
85
- <span class="badge badge-<%= event[:type] %>"><%= event[:type] %></span>
85
+ <span class="badge <%= event_type_badge_class(event[:type]) %>"><%= event[:type] %></span>
86
86
  <span class="timeline-time"><%= event[:timestamp].strftime('%b %d, %Y %H:%M:%S') %></span>
87
87
  </div>
88
88
  <% if event[:details].present? %>