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,593 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <script>
5
+ (function() {
6
+ var theme = localStorage.getItem('sincerely-theme');
7
+ if (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches) {
8
+ theme = 'dark';
9
+ }
10
+ if (theme) {
11
+ document.documentElement.setAttribute('data-theme', theme);
12
+ }
13
+ })();
14
+ </script>
15
+ <meta charset="UTF-8">
16
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
17
+ <title>Sincerely Dashboard</title>
18
+ <%= csrf_meta_tags %>
19
+ <link rel="preconnect" href="https://fonts.googleapis.com">
20
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
21
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Jersey+10&display=swap" rel="stylesheet">
22
+ <style>
23
+ :root {
24
+ --primary: #D4183D;
25
+ --primary-dark: #70041E;
26
+ --success: #8ACB88;
27
+ --warning: #78c6cc;
28
+ --danger: #D4183D;
29
+ --gray-50: #f9fafb;
30
+ --gray-100: #f3f4f6;
31
+ --gray-200: #e5e7eb;
32
+ --gray-300: #d1d5db;
33
+ --gray-500: #6b7280;
34
+ --gray-700: #374151;
35
+ --gray-900: #111827;
36
+ --bg-primary: #ffffff;
37
+ --bg-secondary: #E6E6E6;
38
+ --text-primary: #70041E;
39
+ --text-secondary: #717182;
40
+ --border-color: #e5e7eb;
41
+ }
42
+
43
+ [data-theme="dark"] {
44
+ --primary: #D4183D;
45
+ --primary-dark: #490C13;
46
+ --gray-50: #1f2937;
47
+ --gray-100: #374151;
48
+ --gray-200: #4b5563;
49
+ --gray-300: #6b7280;
50
+ --gray-500: #9ca3af;
51
+ --gray-700: #d1d5db;
52
+ --gray-900: #f9fafb;
53
+ --bg-primary: #1E1E1E;
54
+ /* --bg-primary: #9393A1; */
55
+ --bg-secondary: #303030;
56
+ /* --bg-secondary: #1E1E1E; */
57
+ --text-primary: #f9fafb;
58
+ --text-secondary: #9ca3af;
59
+ --border-color: #374151;
60
+ }
61
+
62
+ * { box-sizing: border-box; margin: 0; padding: 0; }
63
+
64
+ html, body { height: 100%; }
65
+
66
+ body {
67
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
68
+ background: var(--bg-secondary);
69
+ color: var(--text-primary);
70
+ line-height: 1.5;
71
+ transition: background-color 0.3s, color 0.3s;
72
+ display: flex;
73
+ flex-direction: column;
74
+ overflow: hidden;
75
+ }
76
+
77
+ @media (max-width: 768px) {
78
+ body { overflow: auto; }
79
+ }
80
+
81
+ a { color: var(--primary); }
82
+ .container { max-width: 1280px; margin: 0 auto; padding: 0 1rem; }
83
+
84
+ /* Navigation */
85
+ .nav { background: var(--bg-primary); border-bottom: 1px solid var(--border-color); padding: 1rem 0; transition: background-color 0.3s; z-index: 50; box-shadow: 0 1px 3px rgba(0,0,0,0.08); flex-shrink: 0; }
86
+ .nav-inner { display: flex; align-items: center; flex-wrap: wrap; gap: 0.5rem; position: relative; }
87
+ .nav-brand { font-family: 'Jersey 10', cursive; font-weight: 400; font-size: 2.25rem; color: #717182; text-decoration: none; }
88
+ [data-theme="dark"] .nav-brand { color: #9393A1; }
89
+ .nav-toggle { display: none; background: none; border: 1px solid var(--border-color); border-radius: 0.375rem; padding: 0.5rem; cursor: pointer; color: var(--text-secondary); height: 2.25rem; align-items: center; justify-content: center; }
90
+ .nav-toggle svg { width: 1.25rem; height: 1.25rem; display: block; }
91
+ .nav-links { display: flex; gap: 1.5rem; align-items: center; margin-left: 2rem; }
92
+ .nav-links a { color: var(--text-secondary); text-decoration: none; font-weight: 500; padding: 0.5rem 0; border-bottom: 2px solid transparent; }
93
+ .nav-links a:hover, .nav-links a.active { color: var(--primary); border-bottom-color: var(--primary); }
94
+ .nav-utils { display: flex; align-items: center; gap: 0.5rem; margin-left: auto; }
95
+
96
+ /* Theme Toggle */
97
+ .theme-toggle { background: none; border: 1px solid var(--border-color); border-radius: 0.375rem; padding: 0.5rem; cursor: pointer; color: var(--text-secondary); display: flex; align-items: center; justify-content: center; transition: all 0.2s; height: 2.25rem; }
98
+ .theme-toggle:hover { background: var(--gray-100); color: var(--text-primary); }
99
+ .theme-toggle svg { width: 1.25rem; height: 1.25rem; }
100
+ .theme-toggle .icon-sun { display: none; }
101
+ .theme-toggle .icon-moon { display: block; }
102
+ [data-theme="dark"] .theme-toggle .icon-sun { display: block; }
103
+ [data-theme="dark"] .theme-toggle .icon-moon { display: none; }
104
+
105
+ /* Main */
106
+ .main { padding: 2rem 0; flex: 1; overflow-y: auto; }
107
+ @media (max-width: 768px) { .main { overflow-y: visible; } }
108
+ .page-header { margin-bottom: 2rem; display: none; flex-direction: column; gap: 0.25rem; }
109
+ .page-header--visible { display: flex; }
110
+ .page-header-top { display: flex; align-items: center; gap: 0.75rem; }
111
+ .btn-back { display: flex; align-items: center; justify-content: center; width: 2.25rem; height: 2.25rem; background: none; border: 1px solid var(--border-color); border-radius: 0.375rem; cursor: pointer; color: var(--text-secondary); transition: all 0.2s; }
112
+ .btn-back:hover { background: var(--gray-100); color: var(--text-primary); }
113
+ .page-title { font-family: 'Jersey 10', cursive; font-size: 2rem; font-weight: 400; color: var(--gray-900); }
114
+ .page-subtitle { color: var(--gray-500); }
115
+
116
+ /* Cards */
117
+ .card { background: var(--bg-primary); border-radius: 0.5rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1); padding: 1.5rem; margin-bottom: 1.5rem; transition: background-color 0.3s; overflow: hidden; min-width: 0; }
118
+ .card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; padding-bottom: 1rem; border-bottom: 1px solid var(--border-color); }
119
+ .card-title { font-family: 'Jersey 10', cursive; font-weight: 400; font-size: 1.5rem; }
120
+
121
+ /* Stats */
122
+ .stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin-bottom: 2rem; }
123
+ .stat-card { background: var(--bg-primary); border-radius: 0.5rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1); padding: 1.5rem; transition: background-color 0.3s; min-width: 0; overflow: hidden; }
124
+ .stat-label { color: var(--gray-500); font-size: 0.875rem; }
125
+ .stat-value { font-size: 2rem; font-weight: 700; margin-top: 0.25rem; }
126
+ .stat-value.primary { color: #8ACB88; }
127
+ .stat-value.success { color: var(--success); }
128
+ .stat-value.warning { color: var(--warning); }
129
+ .stat-value.danger { color: var(--danger); }
130
+
131
+ /* Tables */
132
+ .table-wrapper { overflow-x: auto; -webkit-overflow-scrolling: touch; }
133
+ table { width: 100%; border-collapse: collapse; }
134
+ th, td { text-align: left; padding: 0.75rem 1rem; border-bottom: 1px solid var(--border-color); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
135
+ th { background: var(--gray-100); font-weight: 600; font-size: 0.875rem; color: var(--text-secondary); transition: background-color 0.3s; }
136
+ tr:hover { background: var(--gray-100); }
137
+ tr.clickable-row { cursor: pointer; transition: background-color 0.15s; }
138
+ tr.clickable-row:hover { background: var(--gray-200); }
139
+ tr.clickable-row td a { color: inherit; text-decoration: none; }
140
+
141
+ /* Badges */
142
+ .badge { display: inline-block; padding: 0.25rem 0.75rem; border-radius: 9999px; font-size: 0.75rem; font-weight: 500; }
143
+ .badge-draft { background: var(--gray-100); color: var(--gray-700); }
144
+ .badge-accepted { background: #dbeafe; color: #1d4ed8; }
145
+ .badge-delivered { background: #d1fae5; color: #047857; }
146
+ .badge-opened { background: #fef3c7; color: #b45309; }
147
+ .badge-clicked { background: #ddd6fe; color: #7c3aed; }
148
+ .badge-bounced, .badge-rejected, .badge-complained { background: #fee2e2; color: #dc2626; }
149
+ .badge-delayed { background: #fef3c7; color: #b45309; }
150
+ .badge-delivery { background: #d1fae5; color: #047857; }
151
+ .badge-bounce { background: #fee2e2; color: #dc2626; }
152
+ .badge-open { background: #fef3c7; color: #b45309; }
153
+ .badge-click { background: #ddd6fe; color: #7c3aed; }
154
+ .badge-complaint { background: #fee2e2; color: #dc2626; }
155
+
156
+ /* Buttons */
157
+ .btn { display: inline-flex; align-items: center; gap: 0.5rem; padding: 0.5rem 1rem; border-radius: 0.375rem; font-weight: 500; font-size: 0.875rem; text-decoration: none; cursor: pointer; border: none; transition: background-color 0.2s; }
158
+ .btn-primary { background: var(--primary); color: white; }
159
+ .btn-primary:hover { background: var(--primary-dark); }
160
+ .btn-secondary { background: var(--bg-primary); color: var(--text-secondary); border: 1px solid var(--border-color); }
161
+ .btn-secondary:hover { background: var(--gray-100); }
162
+ .btn-sm { padding: 0.375rem 0.75rem; font-size: 0.75rem; height: 2rem; }
163
+ .btn-icon { display: inline-flex; align-items: center; justify-content: center; width: 2rem; height: 2rem; border-radius: 0.375rem; color: var(--text-secondary); transition: all 0.15s; }
164
+ .btn-icon:hover { background: var(--gray-100); color: var(--primary); }
165
+ .actions-cell { display: flex; gap: 0.25rem; }
166
+
167
+ /* Forms */
168
+ .form-group { margin-bottom: 1rem; }
169
+ .form-label { display: block; font-weight: 500; margin-bottom: 0.5rem; font-size: 0.875rem; }
170
+ .form-input, .form-select, .form-textarea { width: 100%; padding: 0.5rem 0.75rem; border: 1px solid var(--gray-300); border-radius: 0.375rem; font-size: 0.875rem; }
171
+ .form-input:focus, .form-select:focus, .form-textarea:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1); }
172
+ .form-textarea { min-height: 150px; font-family: monospace; }
173
+
174
+ /* Filters */
175
+ .filters { display: flex; gap: 1rem; flex-wrap: wrap; align-items: flex-end; margin-bottom: 1.5rem; padding: 1rem; background: white; border-radius: 0.5rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
176
+ .filter-group { flex: 1; min-width: 150px; }
177
+ .filter-group label { display: block; font-size: 0.75rem; color: var(--gray-500); margin-bottom: 0.25rem; }
178
+
179
+ /* Timeline */
180
+ .timeline { position: relative; padding-left: 2rem; }
181
+ .timeline::before { content: ''; position: absolute; left: 0.5rem; top: 0; bottom: 0; width: 2px; background: var(--gray-200); }
182
+ .timeline-item { position: relative; padding-bottom: 1.5rem; }
183
+ .timeline-item::before { content: ''; position: absolute; left: -1.5rem; top: 0.25rem; width: 0.75rem; height: 0.75rem; border-radius: 50%; background: var(--primary); border: 2px solid white; }
184
+ .timeline-item.delivery::before { background: var(--success); }
185
+ .timeline-item.engagement::before { background: var(--warning); }
186
+ .timeline-item.error::before { background: var(--danger); }
187
+ .timeline-time { font-size: 0.75rem; color: var(--gray-500); }
188
+ .timeline-content { margin-top: 0.25rem; }
189
+
190
+ /* Detail */
191
+ .detail-row { display: flex; padding: 0.75rem 0; border-bottom: 1px solid var(--gray-100); }
192
+ .detail-label { width: 150px; font-weight: 500; color: var(--gray-500); }
193
+ .detail-value { flex: 1; }
194
+
195
+ /* Grid */
196
+ .grid { display: grid; gap: 1.5rem; }
197
+ .grid-2 { grid-template-columns: repeat(2, 1fr); }
198
+ .grid > * { min-width: 0; }
199
+
200
+ /* Pagination */
201
+ .pagination { display: flex; justify-content: center; align-items: center; gap: 0.5rem; margin-top: 1.5rem; padding: 1rem; }
202
+ .pagination-link { padding: 0.5rem 1rem; border-radius: 0.375rem; text-decoration: none; color: var(--text-secondary); background: var(--bg-primary); border: 1px solid var(--border-color); font-size: 0.875rem; transition: background-color 0.2s; }
203
+ .pagination-link:hover:not(.disabled) { background: var(--gray-100); }
204
+ .pagination-link.disabled { color: var(--gray-300); cursor: not-allowed; }
205
+ .pagination-info { font-size: 0.875rem; color: var(--text-secondary); }
206
+
207
+ /* Filters Form */
208
+ .filters-form { padding: 1rem; }
209
+ .filters-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1rem; margin-bottom: 1rem; }
210
+ .filter-group label { display: block; font-size: 0.75rem; font-weight: 500; color: var(--gray-500); margin-bottom: 0.25rem; text-transform: uppercase; letter-spacing: 0.05em; }
211
+ .filter-group input, .filter-group select { width: 100%; padding: 0.5rem 0.75rem; border: 1px solid var(--border-color); border-radius: 0.375rem; font-size: 0.875rem; background: var(--bg-primary); color: var(--text-primary); transition: background-color 0.3s, border-color 0.2s; }
212
+ .filter-group input:focus, .filter-group select:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1); }
213
+ .filter-actions { display: flex; gap: 0.5rem; }
214
+
215
+ /* Details Grid */
216
+ .details-grid { padding: 0.5rem 0; }
217
+ .detail-row { display: flex; padding: 0.75rem 0; border-bottom: 1px solid var(--gray-100); }
218
+ .detail-row:last-child { border-bottom: none; }
219
+ .detail-label { width: 140px; flex-shrink: 0; font-size: 0.875rem; font-weight: 500; color: var(--gray-500); }
220
+ .detail-value { flex: 1; font-size: 0.875rem; }
221
+
222
+ /* Code Block */
223
+ .code-block { background: var(--gray-50); border-radius: 0.375rem; padding: 1rem; overflow-x: auto; }
224
+ .code-block pre { margin: 0; font-family: 'SF Mono', Consolas, monospace; font-size: 0.8125rem; white-space: pre-wrap; word-break: break-all; }
225
+ code { background: var(--gray-100); padding: 0.125rem 0.375rem; border-radius: 0.25rem; font-family: 'SF Mono', Consolas, monospace; font-size: 0.8125rem; }
226
+
227
+ /* Timeline */
228
+ .timeline { position: relative; padding: 1rem; }
229
+ .timeline-item { display: flex; gap: 1rem; padding-bottom: 1.5rem; position: relative; }
230
+ .timeline-item:not(:last-child)::after { content: ''; position: absolute; left: 6px; top: 24px; bottom: 0; width: 2px; background: var(--gray-200); }
231
+ .timeline-marker { width: 14px; height: 14px; border-radius: 50%; background: var(--gray-300); flex-shrink: 0; margin-top: 4px; }
232
+ .timeline-marker.badge-delivered, .timeline-marker.badge-delivery { background: var(--success); }
233
+ .timeline-marker.badge-opened, .timeline-marker.badge-open { background: var(--warning); }
234
+ .timeline-marker.badge-clicked, .timeline-marker.badge-click { background: #7c3aed; }
235
+ .timeline-marker.badge-bounced, .timeline-marker.badge-bounce, .timeline-marker.badge-complained, .timeline-marker.badge-complaint { background: var(--danger); }
236
+ .timeline-content { flex: 1; }
237
+ .timeline-header { display: flex; align-items: center; gap: 0.75rem; }
238
+ .timeline-time { font-size: 0.75rem; color: var(--gray-500); }
239
+ .timeline-details { margin-top: 0.5rem; font-size: 0.875rem; color: var(--gray-700); }
240
+
241
+ /* Form Styles */
242
+ .form-content { padding: 0.5rem 0; }
243
+ .form-group { margin-bottom: 1.25rem; }
244
+ .form-group label { display: block; font-weight: 500; margin-bottom: 0.375rem; font-size: 0.875rem; color: var(--gray-700); }
245
+ .form-control { width: 100%; padding: 0.625rem 0.875rem; border: 1px solid var(--border-color); border-radius: 0.375rem; font-size: 0.875rem; transition: border-color 0.15s, box-shadow 0.15s, background-color 0.3s; background: var(--bg-primary); color: var(--text-primary); }
246
+ .form-control:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1); }
247
+ .form-hint { display: block; margin-top: 0.375rem; font-size: 0.75rem; color: var(--gray-500); }
248
+ .code-editor { font-family: 'SF Mono', Consolas, monospace; font-size: 0.8125rem; min-height: 200px; }
249
+ .form-actions { display: flex; gap: 0.75rem; margin-top: 1.5rem; padding-top: 1.5rem; border-top: 1px solid var(--gray-200); }
250
+
251
+ /* Alert */
252
+ .alert { padding: 1rem; border-radius: 0.375rem; margin-bottom: 1.25rem; }
253
+ .alert-danger { background: #fee2e2; color: #dc2626; border: 1px solid #fecaca; }
254
+ .alert ul { margin: 0.5rem 0 0 1.25rem; }
255
+
256
+ /* Tips */
257
+ .tips-content { padding: 0.5rem 0; font-size: 0.875rem; color: var(--gray-700); }
258
+ .tips-content h4 { font-size: 0.8125rem; font-weight: 600; color: var(--gray-900); margin-bottom: 0.25rem; }
259
+ .tips-content p { margin-bottom: 0.5rem; }
260
+ .tips-content code { font-size: 0.75rem; }
261
+ .tips-content pre { background: var(--gray-50); padding: 0.5rem; border-radius: 0.25rem; font-size: 0.75rem; overflow-x: auto; }
262
+
263
+ /* Stats Mini */
264
+ .stats-mini { display: flex; gap: 2rem; padding: 1rem 0; }
265
+ .stat-mini { text-align: center; }
266
+ .stat-mini-value { font-size: 2rem; font-weight: 700; color: var(--primary); }
267
+ .stat-mini-label { font-size: 0.75rem; color: var(--gray-500); margin-top: 0.25rem; }
268
+
269
+ /* Header Actions */
270
+ .header-actions { display: flex; align-items: center; gap: 0.5rem; }
271
+ /* Event Details */
272
+ .event-details { margin-top: 0.5rem; background: var(--gray-50); padding: 0.75rem; border-radius: 0.25rem; font-size: 0.75rem; max-height: 200px; overflow: auto; }
273
+
274
+ /* Empty State */
275
+ .empty-state { text-align: center; padding: 2rem; color: var(--gray-500); }
276
+
277
+ /* Donut Chart */
278
+ .donut-chart-container { display: flex; flex-direction: column; align-items: center; gap: 1.5rem; padding: 1rem 0; }
279
+ .donut-chart-wrapper { position: relative; width: 300px; height: 300px; flex-shrink: 0; }
280
+ .donut-chart { width: 100%; height: 100%; transform: rotate(-90deg); }
281
+ .donut-segment { fill: none; stroke-width: 32; stroke-linecap: butt; transition: opacity 0.2s, filter 0.2s; cursor: pointer; }
282
+ .donut-segment.dimmed { opacity: 0.3; filter: grayscale(60%); }
283
+ .donut-segment.highlighted { filter: brightness(1.1); }
284
+ .donut-center { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); text-align: center; pointer-events: none; }
285
+ .donut-center-value { font-size: 2rem; font-weight: 700; color: var(--text-primary); line-height: 1; }
286
+ .donut-center-label { font-size: 0.75rem; color: var(--text-secondary); margin-top: 0.25rem; }
287
+ .donut-legend { display: flex; flex-wrap: wrap; justify-content: center; gap: 0.5rem; width: 100%; }
288
+ .donut-legend-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0.625rem; border-radius: 0.375rem; cursor: pointer; transition: background-color 0.15s, opacity 0.2s; }
289
+ .donut-legend-item:hover { background: var(--gray-100); }
290
+ .donut-legend-item.dimmed { opacity: 0.4; }
291
+ .donut-legend-item.highlighted { background: var(--gray-100); }
292
+ .donut-legend-color { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
293
+ .donut-legend-text { font-size: 0.8125rem; font-weight: 500; color: var(--text-primary); }
294
+ .donut-legend-count { font-size: 0.8125rem; font-weight: 600; color: var(--text-secondary); }
295
+ .donut-empty { display: flex; align-items: center; justify-content: center; height: 200px; color: var(--text-secondary); font-size: 0.875rem; }
296
+
297
+ /* Line Chart */
298
+ .line-chart-container { padding: 1rem 0; }
299
+ .line-chart-wrapper { position: relative; width: 100%; height: 280px; }
300
+ .line-chart { width: 100%; height: 100%; }
301
+ .line-chart-line { fill: none; stroke-width: 2.5; stroke-linecap: round; stroke-linejoin: round; transition: opacity 0.2s, stroke-width 0.2s; cursor: pointer; }
302
+ .line-chart-line.dimmed { opacity: 0.15; }
303
+ .line-chart-line.highlighted { stroke-width: 4; }
304
+ .line-chart-area { opacity: 0.1; transition: opacity 0.2s; }
305
+ .line-chart-area.dimmed { opacity: 0.02; }
306
+ .line-chart-area.highlighted { opacity: 0.2; }
307
+ .line-chart-axis { stroke: var(--border-color); stroke-width: 1; }
308
+ .line-chart-grid { stroke: var(--border-color); stroke-width: 0.5; opacity: 0.5; }
309
+ .line-chart-label { font-size: 0.625rem; fill: var(--text-secondary); }
310
+ .line-chart-label-y { text-anchor: end; }
311
+ .line-chart-label-x { text-anchor: middle; }
312
+ .line-chart-legend { display: flex; flex-wrap: wrap; justify-content: center; gap: 0.5rem; margin-top: 1rem; }
313
+ .line-chart-legend-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0.625rem; border-radius: 0.375rem; cursor: pointer; transition: background-color 0.15s, opacity 0.2s; }
314
+ .line-chart-legend-item:hover { background: var(--gray-100); }
315
+ .line-chart-legend-item.dimmed { opacity: 0.4; }
316
+ .line-chart-legend-item.highlighted { background: var(--gray-100); }
317
+ .line-chart-empty { display: flex; align-items: center; justify-content: center; height: 280px; color: var(--text-secondary); font-size: 0.875rem; }
318
+
319
+ /* Time Filter Dropdown */
320
+ .time-dropdown { position: relative; }
321
+ .time-dropdown-btn { display: flex; align-items: center; gap: 0.375rem; background: none; border: 1px solid var(--border-color); border-radius: 0.375rem; padding: 0 0.625rem; cursor: pointer; color: var(--text-secondary); font-size: 0.8125rem; font-weight: 500; transition: all 0.2s; height: 2.25rem; }
322
+ .time-dropdown-btn:hover { background: var(--gray-100); color: var(--text-primary); }
323
+ .time-dropdown-btn svg { width: 1rem; height: 1rem; }
324
+ .time-dropdown-menu { position: absolute; top: 100%; right: 0; margin-top: 0.25rem; background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 0.5rem; box-shadow: 0 4px 12px rgba(0,0,0,0.15); min-width: 140px; z-index: 100; display: none; }
325
+ .time-dropdown.open .time-dropdown-menu { display: block; }
326
+ .time-dropdown-item { display: block; width: 100%; padding: 0.5rem 0.75rem; border: none; background: none; text-align: left; font-size: 0.8125rem; color: var(--text-secondary); cursor: pointer; transition: all 0.15s; }
327
+ .time-dropdown-item:first-child { border-radius: 0.5rem 0.5rem 0 0; }
328
+ .time-dropdown-item:last-child { border-radius: 0 0 0.5rem 0.5rem; }
329
+ .time-dropdown-item:hover { background: var(--gray-100); color: var(--text-primary); }
330
+ .time-dropdown-item.active { background: var(--primary); color: white; }
331
+
332
+ /* Flash */
333
+ .flash { padding: 1rem; border-radius: 0.375rem; margin-bottom: 1rem; }
334
+ .flash-notice { background: #d1fae5; color: #047857; }
335
+ .flash-alert { background: #fee2e2; color: #dc2626; }
336
+
337
+ /* Responsive */
338
+ @media (max-width: 768px) {
339
+ .grid-2 { grid-template-columns: 1fr; }
340
+ .page-header { display: flex; justify-content: space-between; align-items: flex-start; }
341
+ .nav-toggle { display: block; order: 1; position: relative; }
342
+ .nav-brand { order: 2; }
343
+ .nav-utils { order: 3; margin-left: auto; }
344
+ .nav-links {
345
+ display: none;
346
+ flex-direction: column;
347
+ position: absolute;
348
+ top: 100%;
349
+ left: 1rem;
350
+ margin-top: 0.25rem;
351
+ background: var(--bg-primary);
352
+ border: 1px solid var(--border-color);
353
+ border-radius: 0.5rem;
354
+ box-shadow: 0 4px 12px rgba(0,0,0,0.15);
355
+ min-width: 180px;
356
+ z-index: 100;
357
+ margin-left: 0;
358
+ gap: 0;
359
+ padding: 0;
360
+ }
361
+ .nav-links.open { display: flex; }
362
+ .nav-links a {
363
+ padding: 0.625rem 1rem;
364
+ border-bottom: none;
365
+ color: var(--text-secondary);
366
+ transition: all 0.15s;
367
+ }
368
+ .nav-links a:first-child { border-radius: 0.5rem 0.5rem 0 0; }
369
+ .nav-links a:last-child { border-radius: 0 0 0.5rem 0.5rem; }
370
+ .nav-links a:hover { color: var(--text-primary); }
371
+ .nav-links a.active { color: var(--primary); font-weight: 600; }
372
+ .stats-grid { grid-template-columns: repeat(2, 1fr); }
373
+ .stat-value { font-size: 1.5rem; }
374
+ .donut-chart-wrapper { width: 220px; height: 220px; }
375
+ .line-chart-wrapper { height: 200px; }
376
+ .card { padding: 1rem; }
377
+ .card-header { flex-direction: column; align-items: flex-start; gap: 0.5rem; }
378
+ .main { padding: 1rem 0; }
379
+ }
380
+
381
+ @media (max-width: 480px) {
382
+ .stats-grid { grid-template-columns: 1fr; }
383
+ .donut-chart-wrapper { width: 180px; height: 180px; }
384
+ .donut-center-value { font-size: 1.5rem; }
385
+ }
386
+
387
+ /* Return Footer */
388
+ .return-footer {
389
+ position: fixed;
390
+ bottom: 0;
391
+ left: 0;
392
+ right: 0;
393
+ background: var(--bg-primary);
394
+ border-top: 1px solid var(--border-color);
395
+ padding: 0.5rem 1rem;
396
+ z-index: 40;
397
+ opacity: 0.85;
398
+ transition: opacity 0.2s, background-color 0.3s;
399
+ }
400
+ .return-footer:hover { opacity: 1; }
401
+ .return-footer-link {
402
+ display: inline-flex;
403
+ align-items: center;
404
+ gap: 0.375rem;
405
+ color: var(--text-secondary);
406
+ text-decoration: none;
407
+ font-size: 0.8125rem;
408
+ transition: color 0.15s;
409
+ }
410
+ .return-footer-link:hover { color: var(--primary); }
411
+ .return-footer-link svg { width: 0.875rem; height: 0.875rem; }
412
+ .main.has-return-footer { padding-bottom: 3.5rem; }
413
+ </style>
414
+ </head>
415
+ <body>
416
+ <nav class="nav">
417
+ <div class="container nav-inner">
418
+ <button class="nav-toggle" onclick="toggleNav()" aria-label="Toggle navigation">
419
+ <svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path></svg>
420
+ </button>
421
+ <a href="<%= sincerely.root_path %>" class="nav-brand">Sincerely</a>
422
+ <div class="nav-links" id="navLinks">
423
+ <a href="<%= sincerely.root_path %>" class="<%= 'active' if controller_name == 'dashboard' %>">Dashboard</a>
424
+ <a href="<%= sincerely.notifications_path %>" class="<%= 'active' if controller_name == 'notifications' %>">Notifications</a>
425
+ <a href="<%= sincerely.delivery_events_path %>" class="<%= 'active' if controller_name == 'delivery_events' %>">Delivery</a>
426
+ <a href="<%= sincerely.engagement_events_path %>" class="<%= 'active' if controller_name == 'engagement_events' %>">Engagement</a>
427
+ <a href="<%= sincerely.templates_path %>" class="<%= 'active' if controller_name == 'templates' %>">Templates</a>
428
+ <a href="<%= sincerely.send_new_path %>" class="<%= 'active' if controller_name == 'send' %>">Send</a>
429
+ </div>
430
+ <div class="nav-utils">
431
+ <% if !%w[templates send].include?(controller_name) %>
432
+ <% time_labels = { '1h' => '1h', '24h' => '24h', '7d' => '7d', '30d' => '30d', '3m' => '3m', 'all' => 'All' } %>
433
+ <div class="time-dropdown" id="timeDropdown">
434
+ <button class="time-dropdown-btn" onclick="toggleTimeDropdown()" title="Time filter">
435
+ <svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
436
+ <span id="timeLabel"><%= time_labels[params[:period]] || '24h' %></span>
437
+ </button>
438
+ <div class="time-dropdown-menu">
439
+ <button class="time-dropdown-item" data-period="1h">1 hour</button>
440
+ <button class="time-dropdown-item" data-period="24h">24 hours</button>
441
+ <button class="time-dropdown-item" data-period="7d">7 days</button>
442
+ <button class="time-dropdown-item" data-period="30d">30 days</button>
443
+ <button class="time-dropdown-item" data-period="3m">3 months</button>
444
+ <button class="time-dropdown-item" data-period="all">All time</button>
445
+ </div>
446
+ </div>
447
+ <% end %>
448
+ <button class="theme-toggle" onclick="toggleTheme()" title="Toggle theme">
449
+ <svg class="icon-moon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"></path></svg>
450
+ <svg class="icon-sun" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"></path></svg>
451
+ </button>
452
+ <% if Sincerely.authenticate_with.present? && Sincerely.config.logout_url.present? %>
453
+ <a href="<%= Sincerely.config.logout_url %>" class="theme-toggle" title="<%= Sincerely.config.logout_label.presence || 'Logout' %>"<%= " data-turbo-method=\"#{Sincerely.config.logout_method}\"".html_safe if Sincerely.config.logout_method.present? %>>
454
+ <svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"></path></svg>
455
+ </a>
456
+ <% end %>
457
+ </div>
458
+ </div>
459
+ </nav>
460
+
461
+ <main class="main<%= ' has-return-footer' if Sincerely.config.return_url.present? %>">
462
+ <div class="container">
463
+ <% if flash[:notice] %>
464
+ <div class="flash flash-notice"><%= flash[:notice] %></div>
465
+ <% end %>
466
+ <% if flash[:alert] %>
467
+ <div class="flash flash-alert"><%= flash[:alert] %></div>
468
+ <% end %>
469
+
470
+ <%= yield %>
471
+ </div>
472
+ </main>
473
+
474
+ <% if Sincerely.config.return_url.present? %>
475
+ <footer class="return-footer">
476
+ <a href="<%= Sincerely.config.return_url %>" class="return-footer-link">
477
+ <svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"></path></svg>
478
+ <%= Sincerely.config.return_label.presence || 'Back to app' %>
479
+ </a>
480
+ </footer>
481
+ <% end %>
482
+
483
+ <script>
484
+ const TIME_LABELS = { '1h': '1h', '24h': '24h', '7d': '7d', '30d': '30d', '3m': '3m', 'all': 'All' };
485
+ const FILTERED_PAGES = ['dashboard', 'notifications', 'delivery_events', 'engagement_events'];
486
+ const CURRENT_PAGE = '<%= controller_name %>';
487
+
488
+ function toggleNav() {
489
+ const navLinks = document.getElementById('navLinks');
490
+ if (navLinks) navLinks.classList.toggle('open');
491
+ }
492
+
493
+ function toggleTheme() {
494
+ const html = document.documentElement;
495
+ const currentTheme = html.getAttribute('data-theme');
496
+ const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
497
+ html.setAttribute('data-theme', newTheme);
498
+ localStorage.setItem('sincerely-theme', newTheme);
499
+ }
500
+
501
+ function toggleTimeDropdown() {
502
+ const dropdown = document.getElementById('timeDropdown');
503
+ if (dropdown) dropdown.classList.toggle('open');
504
+ }
505
+
506
+ function setTimePeriod(period) {
507
+ localStorage.setItem('sincerely-period', period);
508
+ const label = document.getElementById('timeLabel');
509
+ if (label) label.textContent = TIME_LABELS[period];
510
+ const dropdown = document.getElementById('timeDropdown');
511
+ if (dropdown) dropdown.classList.remove('open');
512
+ updateActiveItem(period);
513
+
514
+ const url = new URL(window.location);
515
+ url.searchParams.set('period', period);
516
+ window.location = url;
517
+ }
518
+
519
+ function updateActiveItem(period) {
520
+ document.querySelectorAll('.time-dropdown-item').forEach(item => {
521
+ item.classList.toggle('active', item.dataset.period === period);
522
+ });
523
+ }
524
+
525
+ function getSavedPeriod() {
526
+ return localStorage.getItem('sincerely-period') || '24h';
527
+ }
528
+
529
+ document.addEventListener('click', function(e) {
530
+ // Close time dropdown when clicking outside
531
+ const dropdown = document.getElementById('timeDropdown');
532
+ if (dropdown && !dropdown.contains(e.target)) {
533
+ dropdown.classList.remove('open');
534
+ }
535
+
536
+ // Close nav menu when clicking outside
537
+ const navLinks = document.getElementById('navLinks');
538
+ const navToggle = document.querySelector('.nav-toggle');
539
+ if (navLinks && !navLinks.contains(e.target) && !navToggle.contains(e.target)) {
540
+ navLinks.classList.remove('open');
541
+ }
542
+ });
543
+
544
+ document.querySelectorAll('.time-dropdown-item').forEach(item => {
545
+ item.addEventListener('click', () => setTimePeriod(item.dataset.period));
546
+ });
547
+
548
+ document.querySelectorAll('.nav-links a').forEach(link => {
549
+ link.addEventListener('click', function(e) {
550
+ // Close mobile nav on link click
551
+ const navLinks = document.getElementById('navLinks');
552
+ if (navLinks) navLinks.classList.remove('open');
553
+
554
+ const href = this.getAttribute('href');
555
+ const isFilteredPage = FILTERED_PAGES.some(page =>
556
+ href.includes('/' + page) || href.endsWith('/sincerely') || href.endsWith('/sincerely/')
557
+ );
558
+
559
+ if (isFilteredPage) {
560
+ e.preventDefault();
561
+ const url = new URL(href, window.location.origin);
562
+ url.searchParams.set('period', getSavedPeriod());
563
+ window.location = url;
564
+ }
565
+ });
566
+ });
567
+
568
+ (function() {
569
+ const savedTheme = localStorage.getItem('sincerely-theme');
570
+ if (savedTheme) {
571
+ document.documentElement.setAttribute('data-theme', savedTheme);
572
+ } else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
573
+ document.documentElement.setAttribute('data-theme', 'dark');
574
+ }
575
+
576
+ const urlParams = new URLSearchParams(window.location.search);
577
+ const urlPeriod = urlParams.get('period');
578
+ const savedPeriod = getSavedPeriod();
579
+ const period = urlPeriod || savedPeriod;
580
+
581
+ const label = document.getElementById('timeLabel');
582
+ if (label) label.textContent = TIME_LABELS[period] || '24h';
583
+ updateActiveItem(period);
584
+
585
+ if (!urlPeriod && FILTERED_PAGES.includes(CURRENT_PAGE)) {
586
+ const url = new URL(window.location);
587
+ url.searchParams.set('period', savedPeriod);
588
+ window.location.replace(url);
589
+ }
590
+ })();
591
+ </script>
592
+ </body>
593
+ </html>