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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +38 -0
- data/CHANGELOG.md +12 -0
- data/README.md +202 -0
- data/Rakefile +12 -0
- data/app/controllers/sincerely/application_controller.rb +81 -0
- data/app/controllers/sincerely/dashboard_controller.rb +112 -0
- data/app/controllers/sincerely/delivery_events_controller.rb +31 -0
- data/app/controllers/sincerely/engagement_events_controller.rb +32 -0
- data/app/controllers/sincerely/notifications_controller.rb +69 -0
- data/app/controllers/sincerely/send_controller.rb +105 -0
- data/app/controllers/sincerely/templates_controller.rb +61 -0
- data/app/helpers/sincerely/application_helper.rb +39 -0
- data/app/views/layouts/sincerely/application.html.erb +593 -0
- data/app/views/sincerely/dashboard/index.html.erb +382 -0
- data/app/views/sincerely/delivery_events/index.html.erb +97 -0
- data/app/views/sincerely/engagement_events/index.html.erb +97 -0
- data/app/views/sincerely/notifications/index.html.erb +91 -0
- data/app/views/sincerely/notifications/show.html.erb +98 -0
- data/app/views/sincerely/send/new.html.erb +592 -0
- data/app/views/sincerely/shared/_pagination.html.erb +19 -0
- data/app/views/sincerely/templates/_form.html.erb +226 -0
- data/app/views/sincerely/templates/edit.html.erb +11 -0
- data/app/views/sincerely/templates/index.html.erb +59 -0
- data/app/views/sincerely/templates/new.html.erb +11 -0
- data/app/views/sincerely/templates/preview.html.erb +48 -0
- data/app/views/sincerely/templates/show.html.erb +69 -0
- data/config/routes.rb +21 -0
- data/lib/config/application_config.rb +18 -0
- data/lib/config/sincerely_config.rb +31 -0
- data/lib/generators/sincerely/aws_ses_webhook_controller_generator.rb +31 -0
- data/lib/generators/sincerely/events_generator.rb +45 -0
- data/lib/generators/sincerely/install_generator.rb +18 -0
- data/lib/generators/sincerely/migration_generator.rb +65 -0
- data/lib/generators/templates/aws_ses_webhook_controller.rb.erb +3 -0
- data/lib/generators/templates/events/delivery_event_model.rb.erb +5 -0
- data/lib/generators/templates/events/delivery_events_create.rb.erb +20 -0
- data/lib/generators/templates/events/engagement_event_model.rb.erb +5 -0
- data/lib/generators/templates/events/engagement_events_create.rb.erb +21 -0
- data/lib/generators/templates/notification_model.rb.erb +3 -0
- data/lib/generators/templates/notifications_create.rb.erb +21 -0
- data/lib/generators/templates/notifications_update.rb.erb +16 -0
- data/lib/generators/templates/sincerely.yml +21 -0
- data/lib/generators/templates/templates_create.rb.erb +15 -0
- data/lib/sincerely/delivery_systems/email_aws_ses.rb +69 -0
- data/lib/sincerely/engine.rb +7 -0
- data/lib/sincerely/mixins/notification_model.rb +94 -0
- data/lib/sincerely/mixins/webhooks/aws_ses_events.rb +74 -0
- data/lib/sincerely/renderers/liquid.rb +14 -0
- data/lib/sincerely/services/events/aws_ses_bounce_event.rb +26 -0
- data/lib/sincerely/services/events/aws_ses_click_event.rb +25 -0
- data/lib/sincerely/services/events/aws_ses_complaint_event.rb +25 -0
- data/lib/sincerely/services/events/aws_ses_delivery_event.rb +17 -0
- data/lib/sincerely/services/events/aws_ses_event.rb +56 -0
- data/lib/sincerely/services/events/aws_ses_open_event.rb +25 -0
- data/lib/sincerely/services/process_delivery_event.rb +72 -0
- data/lib/sincerely/templates/email_liquid_template.rb +13 -0
- data/lib/sincerely/templates/notification_template.rb +22 -0
- data/lib/sincerely/version.rb +5 -0
- data/lib/sincerely.rb +20 -0
- data/sincerely.gemspec +37 -0
- 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>
|