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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3126012f05e46f992478bb05e997605aef323820d2fd72c95dfe3fa49105c33c
4
- data.tar.gz: 5c5c1b66fc5e956506a634b8c4da25869019f741bb66e8517e28a08eef32c209
3
+ metadata.gz: 6404221d2f3d54bcc784af23015c254f5b6a7cc6f4e6f625b648cba7ce2370f2
4
+ data.tar.gz: f5c4bbee5ad0e3fd813e7995f4b33fc355b129b882420c386422a46508d40c12
5
5
  SHA512:
6
- metadata.gz: 0b093a942bbb2d4ae549e049e47595ca42e645211b1b6d1a92be1e035cac3aa3c690c1f829505bba4e0ae61fbbfa950e08f25a9bc1c4507be2a3e2f5a2555225
7
- data.tar.gz: 4f75a74cd95ec7025a04c6d549591129f6736c9303f07f866fe4213f74ba72a2fe677fc699c668efc405271e09b5b0437679bcb61a19439450400f45323fd58c
6
+ metadata.gz: c8e6d0d7130e5acb752cdb00cd9c15e79c17704851a22dc39232a64cfddfc15d4f3002277354c0db47e2ca5b3677971facf84874d880e34997c6e2dab340718f
7
+ data.tar.gz: 3c296dab84ca998b02dc855073fc53de6bb881a335186317104a6553fd32c80aa0e653c64a8e6ae39680baf9355b1a120917f8e0c912ba33e09ebd604784e3db
data/CHANGELOG.md CHANGED
@@ -2,6 +2,37 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [1.1.0] - 2026-08-28
6
+
7
+ ### Added
8
+ - Mountable notifications dashboard engine: overview stats, delivery/engagement timeline charts, notifications-by-status chart, and configurable time period filters
9
+ - Templates CRUD with live preview (tabbed HTML/text preview, sample data input, Liquid variable extraction including filter syntax e.g. `name | upcase`)
10
+ - Notifications index and detail views with clickable rows, keyboard navigation, template/notification-type/date-range filters, and pagination
11
+ - Delivery and engagement events views with filtering and timelines
12
+ - Manual notification sending from the dashboard ("send" feature)
13
+ - Dark theme with mobile-friendly nav
14
+ - Configurable `return_url`, `return_label`, and `logout_*` options (via YAML or ENV)
15
+ - CI workflow to publish the gem to RubyGems automatically on tag push
16
+ - arm64-darwin platform support
17
+
18
+ ### Changed
19
+ - Dropped the `sincerely.` prefix from view/helper references across dashboard, notifications, templates, delivery/engagement, nav, and send views
20
+ - Moved inline JS/CSS/SVGs out of views into the asset pipeline (Sprockets manifest, dedicated JS/CSS files, icon partials) and replaced inline `onclick`/`style` attributes with `data-*` attributes and CSS classes for CSP compatibility
21
+ - Extracted controller responsibilities into concerns (pagination, periods, event filtering, delivery metrics/timeline)
22
+ - Replaced `pluck`-based `IN` queries with subqueries and added eager loading to avoid N+1 queries; aggregated timeline counts in SQL instead of iterating in Ruby
23
+
24
+ ### Fixed
25
+ - Guarded AASM state transitions (`may_*?`) to handle duplicate or out-of-order webhook events
26
+ - Corrected timeline bucket boundaries, pagination clamping, invalid date-range handling, and donut chart segment math at 100%
27
+ - Returned a 422 (instead of 500) on invalid Liquid template syntax during preview, and generic client errors with full server-side logging on delivery exceptions
28
+ - Replaced the logout anchor with `button_to` so it works without Turbo
29
+ - Assorted nil/default handling fixes for template subjects, content, and `template_data` across param types
30
+
31
+ ### Security
32
+ - Guarded the inline SVG helper against path traversal
33
+ - Sandboxed the template preview iframe and added a CSP
34
+ - Restricted `template_data` to known Liquid variables instead of permitting arbitrary params
35
+
5
36
  ## [1.0.0] - 2024-07-31
6
37
 
7
38
  ### Added
@@ -0,0 +1,2 @@
1
+ //= link_directory ../javascripts/sincerely .js
2
+ //= link_directory ../stylesheets/sincerely .css
@@ -0,0 +1 @@
1
+ <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>
@@ -0,0 +1,3 @@
1
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
2
+ <path d="M20 6L9 17l-5-5"></path>
3
+ </svg>
@@ -0,0 +1 @@
1
+ <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>
@@ -0,0 +1 @@
1
+ <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>
@@ -0,0 +1 @@
1
+ <svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"></path></svg>
@@ -0,0 +1 @@
1
+ <svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path></svg>
@@ -0,0 +1 @@
1
+ <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>
@@ -0,0 +1 @@
1
+ <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>
@@ -0,0 +1 @@
1
+ <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>
@@ -0,0 +1,4 @@
1
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
2
+ <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
3
+ <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
4
+ </svg>
@@ -0,0 +1,3 @@
1
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
2
+ <path d="M12 5v14M5 12h14"></path>
3
+ </svg>
@@ -0,0 +1,3 @@
1
+ <svg class="progress-spinner" viewBox="0 0 50 50">
2
+ <circle cx="25" cy="25" r="20" fill="none" stroke-width="4"></circle>
3
+ </svg>
@@ -0,0 +1,3 @@
1
+ <svg class="spinner" viewBox="0 0 24 24" fill="none" stroke="currentColor">
2
+ <circle cx="12" cy="12" r="10" stroke-width="2" stroke-dasharray="32" stroke-linecap="round"></circle>
3
+ </svg>
@@ -0,0 +1 @@
1
+ <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>
@@ -0,0 +1,4 @@
1
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
2
+ <circle cx="12" cy="12" r="10"></circle>
3
+ <path d="M12 8v4M12 16h.01"></path>
4
+ </svg>
@@ -0,0 +1,115 @@
1
+ (function() {
2
+ // Line Chart interactions
3
+ function initLineChart() {
4
+ const chart = document.getElementById('lineChart');
5
+ if (!chart) return;
6
+
7
+ const lines = chart.querySelectorAll('.line-chart-line');
8
+ const areas = chart.querySelectorAll('.line-chart-area');
9
+ const legendItems = chart.querySelectorAll('.line-chart-legend-item');
10
+
11
+ function highlightState(state) {
12
+ lines.forEach(line => {
13
+ if (line.dataset.state === state) {
14
+ line.classList.add('highlighted');
15
+ line.classList.remove('dimmed');
16
+ } else {
17
+ line.classList.add('dimmed');
18
+ line.classList.remove('highlighted');
19
+ }
20
+ });
21
+ areas.forEach(area => {
22
+ if (area.dataset.state === state) {
23
+ area.classList.add('highlighted');
24
+ area.classList.remove('dimmed');
25
+ } else {
26
+ area.classList.add('dimmed');
27
+ area.classList.remove('highlighted');
28
+ }
29
+ });
30
+ legendItems.forEach(item => {
31
+ if (item.dataset.state === state) {
32
+ item.classList.add('highlighted');
33
+ item.classList.remove('dimmed');
34
+ } else {
35
+ item.classList.add('dimmed');
36
+ item.classList.remove('highlighted');
37
+ }
38
+ });
39
+ }
40
+
41
+ function resetHighlight() {
42
+ lines.forEach(line => line.classList.remove('highlighted', 'dimmed'));
43
+ areas.forEach(area => area.classList.remove('highlighted', 'dimmed'));
44
+ legendItems.forEach(item => item.classList.remove('highlighted', 'dimmed'));
45
+ }
46
+
47
+ legendItems.forEach(item => {
48
+ item.addEventListener('mouseenter', () => highlightState(item.dataset.state));
49
+ item.addEventListener('mouseleave', resetHighlight);
50
+ });
51
+
52
+ lines.forEach(line => {
53
+ line.addEventListener('mouseenter', () => highlightState(line.dataset.state));
54
+ line.addEventListener('mouseleave', resetHighlight);
55
+ });
56
+
57
+ areas.forEach(area => {
58
+ area.addEventListener('mouseenter', () => highlightState(area.dataset.state));
59
+ area.addEventListener('mouseleave', resetHighlight);
60
+ });
61
+ }
62
+
63
+ // Donut Chart interactions
64
+ function initDonutChart() {
65
+ const chart = document.getElementById('donutChart');
66
+ if (!chart) return;
67
+
68
+ const segments = chart.querySelectorAll('.donut-segment');
69
+ const legendItems = chart.querySelectorAll('.donut-legend-item');
70
+
71
+ function highlightState(state) {
72
+ segments.forEach(seg => {
73
+ if (seg.dataset.state === state) {
74
+ seg.classList.add('highlighted');
75
+ seg.classList.remove('dimmed');
76
+ } else {
77
+ seg.classList.add('dimmed');
78
+ seg.classList.remove('highlighted');
79
+ }
80
+ });
81
+ legendItems.forEach(item => {
82
+ if (item.dataset.state === state) {
83
+ item.classList.add('highlighted');
84
+ item.classList.remove('dimmed');
85
+ } else {
86
+ item.classList.add('dimmed');
87
+ item.classList.remove('highlighted');
88
+ }
89
+ });
90
+ }
91
+
92
+ function resetHighlight() {
93
+ segments.forEach(seg => {
94
+ seg.classList.remove('highlighted', 'dimmed');
95
+ });
96
+ legendItems.forEach(item => {
97
+ item.classList.remove('highlighted', 'dimmed');
98
+ });
99
+ }
100
+
101
+ legendItems.forEach(item => {
102
+ item.addEventListener('mouseenter', () => highlightState(item.dataset.state));
103
+ item.addEventListener('mouseleave', resetHighlight);
104
+ });
105
+
106
+ segments.forEach(seg => {
107
+ seg.addEventListener('mouseenter', () => highlightState(seg.dataset.state));
108
+ seg.addEventListener('mouseleave', resetHighlight);
109
+ });
110
+ }
111
+
112
+ // Initialize all charts
113
+ initLineChart();
114
+ initDonutChart();
115
+ })();
@@ -0,0 +1,29 @@
1
+ (function() {
2
+ document.addEventListener('click', function(e) {
3
+ var row = e.target.closest('tr.clickable-row');
4
+ if (!row || !row.dataset.href) return;
5
+ if (e.target.closest('a, button, details, summary')) return;
6
+
7
+ window.location = row.dataset.href;
8
+ });
9
+
10
+ document.addEventListener('keydown', function(e) {
11
+ var row = e.target;
12
+ if (!row.classList.contains('clickable-row')) return;
13
+
14
+ if (e.key === 'Enter' || e.key === ' ') {
15
+ e.preventDefault();
16
+ if (row.dataset.href) window.location = row.dataset.href;
17
+ } else if (e.key === 'ArrowDown') {
18
+ e.preventDefault();
19
+ var next = row.nextElementSibling;
20
+ while (next && !next.classList.contains('clickable-row')) next = next.nextElementSibling;
21
+ if (next) next.focus();
22
+ } else if (e.key === 'ArrowUp') {
23
+ e.preventDefault();
24
+ var prev = row.previousElementSibling;
25
+ while (prev && !prev.classList.contains('clickable-row')) prev = prev.previousElementSibling;
26
+ if (prev) prev.focus();
27
+ }
28
+ });
29
+ })();
@@ -0,0 +1,104 @@
1
+ const TIME_LABELS = { '1h': '1h', '24h': '24h', '7d': '7d', '30d': '30d', '3m': '3m', 'all': 'All' };
2
+ const FILTERED_PAGES = ['dashboard', 'notifications', 'delivery_events', 'engagement_events'];
3
+ const CURRENT_PAGE = document.body.dataset.controller;
4
+
5
+ function toggleNav() {
6
+ const navLinks = document.getElementById('navLinks');
7
+ if (navLinks) navLinks.classList.toggle('open');
8
+ }
9
+
10
+ function toggleTheme() {
11
+ const html = document.documentElement;
12
+ const currentTheme = html.getAttribute('data-theme');
13
+ const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
14
+ html.setAttribute('data-theme', newTheme);
15
+ localStorage.setItem('sincerely-theme', newTheme);
16
+ }
17
+
18
+ function toggleTimeDropdown() {
19
+ const dropdown = document.getElementById('timeDropdown');
20
+ if (dropdown) dropdown.classList.toggle('open');
21
+ }
22
+
23
+ function setTimePeriod(period) {
24
+ localStorage.setItem('sincerely-period', period);
25
+ const label = document.getElementById('timeLabel');
26
+ if (label) label.textContent = TIME_LABELS[period];
27
+ const dropdown = document.getElementById('timeDropdown');
28
+ if (dropdown) dropdown.classList.remove('open');
29
+ updateActiveItem(period);
30
+
31
+ const url = new URL(window.location);
32
+ url.searchParams.set('period', period);
33
+ window.location = url;
34
+ }
35
+
36
+ function updateActiveItem(period) {
37
+ document.querySelectorAll('.time-dropdown-item').forEach(item => {
38
+ item.classList.toggle('active', item.dataset.period === period);
39
+ });
40
+ }
41
+
42
+ function getSavedPeriod() {
43
+ return localStorage.getItem('sincerely-period') || '24h';
44
+ }
45
+
46
+ document.addEventListener('click', function(e) {
47
+ const dropdown = document.getElementById('timeDropdown');
48
+ if (dropdown && !dropdown.contains(e.target)) {
49
+ dropdown.classList.remove('open');
50
+ }
51
+
52
+ const navLinks = document.getElementById('navLinks');
53
+ const navToggle = document.querySelector('.nav-toggle');
54
+ if (navLinks && !navLinks.contains(e.target) && !navToggle.contains(e.target)) {
55
+ navLinks.classList.remove('open');
56
+ }
57
+ });
58
+
59
+ document.querySelectorAll('.time-dropdown-item').forEach(item => {
60
+ item.addEventListener('click', () => setTimePeriod(item.dataset.period));
61
+ });
62
+
63
+ document.querySelectorAll('.nav-links a').forEach(link => {
64
+ link.addEventListener('click', function(e) {
65
+ const navLinks = document.getElementById('navLinks');
66
+ if (navLinks) navLinks.classList.remove('open');
67
+
68
+ const href = this.getAttribute('href');
69
+ const isFilteredPage = FILTERED_PAGES.some(page =>
70
+ href.includes('/' + page) || href.endsWith('/sincerely') || href.endsWith('/sincerely/')
71
+ );
72
+
73
+ if (isFilteredPage) {
74
+ e.preventDefault();
75
+ const url = new URL(href, window.location.origin);
76
+ url.searchParams.set('period', getSavedPeriod());
77
+ window.location = url;
78
+ }
79
+ });
80
+ });
81
+
82
+ (function() {
83
+ const savedTheme = localStorage.getItem('sincerely-theme');
84
+ if (savedTheme) {
85
+ document.documentElement.setAttribute('data-theme', savedTheme);
86
+ } else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
87
+ document.documentElement.setAttribute('data-theme', 'dark');
88
+ }
89
+
90
+ const urlParams = new URLSearchParams(window.location.search);
91
+ const urlPeriod = urlParams.get('period');
92
+ const savedPeriod = getSavedPeriod();
93
+ const period = urlPeriod || savedPeriod;
94
+
95
+ const label = document.getElementById('timeLabel');
96
+ if (label) label.textContent = TIME_LABELS[period] || '24h';
97
+ updateActiveItem(period);
98
+
99
+ if (!urlPeriod && FILTERED_PAGES.includes(CURRENT_PAGE)) {
100
+ const url = new URL(window.location);
101
+ url.searchParams.set('period', savedPeriod);
102
+ window.location.replace(url);
103
+ }
104
+ })();
@@ -0,0 +1,243 @@
1
+ (function() {
2
+ const MAX_RECIPIENTS = 50;
3
+ let recipients = [];
4
+
5
+ const tagsContainer = document.getElementById('tagsContainer');
6
+ const tagsList = document.getElementById('tagsList');
7
+ const recipientInput = document.getElementById('recipientInput');
8
+ const recipientsCount = document.getElementById('recipientsCount');
9
+ const templateSelect = document.getElementById('templateSelect');
10
+ const templateVariables = document.getElementById('templateVariables');
11
+ const variablesGrid = document.getElementById('variablesGrid');
12
+ const sendButton = document.getElementById('sendButton');
13
+ const sendOverlay = document.getElementById('sendOverlay');
14
+ const sendResult = document.getElementById('sendResult');
15
+ const resultSuccess = document.getElementById('resultSuccess');
16
+ const resultPartial = document.getElementById('resultPartial');
17
+ const resultMessage = document.getElementById('resultMessage');
18
+ const resetButton = document.getElementById('resetButton');
19
+ const sendForm = document.querySelector('.send-form');
20
+
21
+ function isValidEmail(email) {
22
+ return /^[^\s@]+@[^\s@]+$/.test(email);
23
+ }
24
+
25
+ function addRecipient(email) {
26
+ email = email.trim().toLowerCase();
27
+ if (!email || recipients.includes(email) || recipients.length >= MAX_RECIPIENTS) return false;
28
+
29
+ recipients.push(email);
30
+ renderTags();
31
+ updateCount();
32
+ updateSendButton();
33
+ return true;
34
+ }
35
+
36
+ function removeRecipient(email) {
37
+ recipients = recipients.filter(r => r !== email);
38
+ renderTags();
39
+ updateCount();
40
+ updateSendButton();
41
+ }
42
+
43
+ function renderTags() {
44
+ tagsList.innerHTML = recipients.map(email => `
45
+ <span class="tag ${isValidEmail(email) ? '' : 'invalid'}">
46
+ ${escapeHtml(email)}
47
+ <button type="button" class="tag-remove" data-email="${escapeHtml(email)}">
48
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
49
+ <path d="M18 6L6 18M6 6l12 12"></path>
50
+ </svg>
51
+ </button>
52
+ </span>
53
+ `).join('');
54
+
55
+ tagsList.querySelectorAll('.tag-remove').forEach(btn => {
56
+ btn.addEventListener('click', (e) => {
57
+ e.stopPropagation();
58
+ removeRecipient(btn.dataset.email);
59
+ });
60
+ });
61
+ }
62
+
63
+ function updateCount() {
64
+ const count = recipients.length;
65
+ recipientsCount.textContent = `${count} / ${MAX_RECIPIENTS} recipients`;
66
+ recipientsCount.className = 'recipients-count';
67
+ if (count >= MAX_RECIPIENTS) {
68
+ recipientsCount.classList.add('limit');
69
+ } else if (count >= 40) {
70
+ recipientsCount.classList.add('warning');
71
+ }
72
+ }
73
+
74
+ function updateSendButton() {
75
+ const hasRecipients = recipients.filter(isValidEmail).length > 0;
76
+ const hasTemplate = templateSelect.value !== '';
77
+ sendButton.disabled = !hasRecipients || !hasTemplate;
78
+ }
79
+
80
+ function escapeHtml(text) {
81
+ const div = document.createElement('div');
82
+ div.textContent = text;
83
+ return div.innerHTML;
84
+ }
85
+
86
+ function parseAndAddEmails(input) {
87
+ const emails = input.split(/[\s,;]+/).filter(e => e.trim());
88
+ emails.forEach(addRecipient);
89
+ }
90
+
91
+ // Input handlers
92
+ recipientInput.addEventListener('keydown', (e) => {
93
+ if (e.key === 'Enter' || e.key === ',' || e.key === ' ' || e.key === 'Tab') {
94
+ e.preventDefault();
95
+ const value = recipientInput.value.trim();
96
+ if (value) {
97
+ parseAndAddEmails(value);
98
+ recipientInput.value = '';
99
+ }
100
+ } else if (e.key === 'Backspace' && !recipientInput.value && recipients.length) {
101
+ removeRecipient(recipients[recipients.length - 1]);
102
+ }
103
+ });
104
+
105
+ recipientInput.addEventListener('paste', (e) => {
106
+ e.preventDefault();
107
+ const pastedText = e.clipboardData.getData('text');
108
+ parseAndAddEmails(pastedText);
109
+ recipientInput.value = '';
110
+ });
111
+
112
+ recipientInput.addEventListener('blur', () => {
113
+ const value = recipientInput.value.trim();
114
+ if (value) {
115
+ parseAndAddEmails(value);
116
+ recipientInput.value = '';
117
+ }
118
+ });
119
+
120
+ tagsContainer.addEventListener('click', () => {
121
+ recipientInput.focus();
122
+ });
123
+
124
+ // Template selection
125
+ templateSelect.addEventListener('change', async () => {
126
+ updateSendButton();
127
+
128
+ const templateId = templateSelect.value;
129
+ if (!templateId) {
130
+ templateVariables.style.display = 'none';
131
+ return;
132
+ }
133
+
134
+ try {
135
+ const variablesUrl = document.getElementById('templateVariablesUrl').dataset.url.replace(':id', templateId);
136
+ const response = await fetch(variablesUrl);
137
+ const data = await response.json();
138
+
139
+ if (data.variables && data.variables.length > 0) {
140
+ variablesGrid.innerHTML = data.variables.map(v => {
141
+ const label = v.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
142
+ return `
143
+ <div class="variable-field">
144
+ <label for="var_${v}">${label}</label>
145
+ <input type="text" id="var_${v}" name="template_data[${v}]" placeholder="${label}...">
146
+ </div>
147
+ `;
148
+ }).join('');
149
+ templateVariables.style.display = 'block';
150
+ } else {
151
+ templateVariables.style.display = 'none';
152
+ }
153
+ } catch (err) {
154
+ console.error('Failed to load template variables:', err);
155
+ templateVariables.style.display = 'none';
156
+ }
157
+ });
158
+
159
+ // Send handler
160
+ sendButton.addEventListener('click', async () => {
161
+ const validRecipients = recipients.filter(isValidEmail);
162
+ if (validRecipients.length === 0) return;
163
+
164
+ const templateId = templateSelect.value;
165
+ if (!templateId) return;
166
+
167
+ // Collect template data
168
+ const templateData = {};
169
+ variablesGrid.querySelectorAll('input').forEach(input => {
170
+ const name = input.name.match(/template_data\[(\w+)\]/);
171
+ if (name) {
172
+ templateData[name[1]] = input.value;
173
+ }
174
+ });
175
+
176
+ // Show loading
177
+ sendOverlay.classList.add('active');
178
+ sendButton.querySelector('.btn-text').style.display = 'none';
179
+ sendButton.querySelector('.btn-loader').style.display = 'inline-flex';
180
+ sendButton.disabled = true;
181
+
182
+ try {
183
+ const createUrl = document.getElementById('sendCreateUrl').dataset.url;
184
+ const response = await fetch(createUrl, {
185
+ method: 'POST',
186
+ headers: {
187
+ 'Content-Type': 'application/json',
188
+ 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
189
+ },
190
+ body: JSON.stringify({
191
+ recipients: validRecipients.join(','),
192
+ template_id: templateId,
193
+ template_data: templateData
194
+ })
195
+ });
196
+
197
+ const result = await response.json();
198
+
199
+ // Hide loading, show result
200
+ sendOverlay.classList.remove('active');
201
+ sendForm.style.display = 'none';
202
+
203
+ if (result.failed > 0) {
204
+ resultSuccess.style.display = 'none';
205
+ resultPartial.style.display = 'flex';
206
+ resultMessage.innerHTML = `<strong>${result.sent}</strong> emails sent, <span class="failed">${result.failed}</span> failed`;
207
+ } else {
208
+ resultSuccess.style.display = 'flex';
209
+ resultPartial.style.display = 'none';
210
+ resultMessage.innerHTML = `<strong>${result.sent}</strong> emails sent successfully!`;
211
+ }
212
+
213
+ sendResult.style.display = 'flex';
214
+ } catch (err) {
215
+ console.error('Send failed:', err);
216
+ sendOverlay.classList.remove('active');
217
+ sendButton.querySelector('.btn-text').style.display = 'inline';
218
+ sendButton.querySelector('.btn-loader').style.display = 'none';
219
+ sendButton.disabled = false;
220
+ alert('Failed to send emails. Please try again.');
221
+ }
222
+ });
223
+
224
+ // Reset handler
225
+ resetButton.addEventListener('click', () => {
226
+ recipients = [];
227
+ renderTags();
228
+ updateCount();
229
+ templateSelect.value = '';
230
+ templateVariables.style.display = 'none';
231
+ variablesGrid.innerHTML = '';
232
+ sendResult.style.display = 'none';
233
+ sendForm.style.display = 'block';
234
+ sendButton.querySelector('.btn-text').style.display = 'inline';
235
+ sendButton.querySelector('.btn-loader').style.display = 'none';
236
+ updateSendButton();
237
+ recipientInput.focus();
238
+ });
239
+
240
+ // Initialize
241
+ updateCount();
242
+ updateSendButton();
243
+ })();
@@ -0,0 +1,44 @@
1
+ (function() {
2
+ function updateHtmlPreview() {
3
+ const textarea = document.getElementById('html_content');
4
+ const iframe = document.getElementById('html_preview');
5
+ if (!textarea || !iframe) return;
6
+
7
+ iframe.srcdoc = textarea.value || '';
8
+ }
9
+
10
+ function updateTextPreview() {
11
+ const textarea = document.getElementById('text_content');
12
+ const preview = document.getElementById('text_preview');
13
+ if (!textarea || !preview) return;
14
+
15
+ preview.textContent = textarea.value || '';
16
+ }
17
+
18
+ document.querySelectorAll('.preview-container').forEach(container => {
19
+ const tabs = container.querySelectorAll('.preview-tab');
20
+ const panels = container.querySelectorAll('.preview-panel');
21
+ const previewType = container.dataset.preview;
22
+
23
+ tabs.forEach(tab => {
24
+ tab.addEventListener('click', () => {
25
+ const target = tab.dataset.target;
26
+
27
+ tabs.forEach(t => t.classList.remove('active'));
28
+ tab.classList.add('active');
29
+
30
+ panels.forEach(p => {
31
+ p.classList.toggle('active', p.dataset.panel === target);
32
+ });
33
+
34
+ if (target === 'preview') {
35
+ if (previewType === 'html') {
36
+ updateHtmlPreview();
37
+ } else if (previewType === 'text') {
38
+ updateTextPreview();
39
+ }
40
+ }
41
+ });
42
+ });
43
+ });
44
+ })();