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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +31 -0
- data/app/assets/config/sincerely.js +2 -0
- data/app/assets/images/sincerely/arrow_left.svg +1 -0
- data/app/assets/images/sincerely/check.svg +3 -0
- data/app/assets/images/sincerely/chevron_left.svg +1 -0
- data/app/assets/images/sincerely/clock.svg +1 -0
- data/app/assets/images/sincerely/code.svg +1 -0
- data/app/assets/images/sincerely/eye.svg +1 -0
- data/app/assets/images/sincerely/logout.svg +1 -0
- data/app/assets/images/sincerely/menu.svg +1 -0
- data/app/assets/images/sincerely/moon.svg +1 -0
- data/app/assets/images/sincerely/pencil.svg +4 -0
- data/app/assets/images/sincerely/plus.svg +3 -0
- data/app/assets/images/sincerely/progress_spinner.svg +3 -0
- data/app/assets/images/sincerely/spinner.svg +3 -0
- data/app/assets/images/sincerely/sun.svg +1 -0
- data/app/assets/images/sincerely/warning_circle.svg +4 -0
- data/app/assets/javascripts/sincerely/charts.js +115 -0
- data/app/assets/javascripts/sincerely/clickable_rows.js +29 -0
- data/app/assets/javascripts/sincerely/layout.js +104 -0
- data/app/assets/javascripts/sincerely/send_form.js +243 -0
- data/app/assets/javascripts/sincerely/template_form.js +44 -0
- data/app/assets/javascripts/sincerely/template_preview.js +12 -0
- data/app/assets/javascripts/sincerely/theme_init.js +9 -0
- data/app/assets/stylesheets/sincerely/charts.css +250 -0
- data/app/assets/stylesheets/sincerely/components.css +632 -0
- data/app/assets/stylesheets/sincerely/layout.css +233 -0
- data/app/assets/stylesheets/sincerely/navigation.css +198 -0
- data/app/assets/stylesheets/sincerely/send_form.css +287 -0
- data/app/assets/stylesheets/sincerely/template_form.css +74 -0
- data/app/assets/stylesheets/sincerely/template_preview.css +75 -0
- data/app/assets/stylesheets/sincerely/variables.css +88 -0
- data/app/controllers/sincerely/application_controller.rb +3 -72
- data/app/controllers/sincerely/concerns/authorization.rb +21 -0
- data/app/controllers/sincerely/concerns/delivery_metrics.rb +51 -0
- data/app/controllers/sincerely/concerns/event_filtering.rb +58 -0
- data/app/controllers/sincerely/concerns/event_timeline.rb +41 -0
- data/app/controllers/sincerely/concerns/notification_timeline.rb +73 -0
- data/app/controllers/sincerely/concerns/notifications.rb +47 -0
- data/app/controllers/sincerely/concerns/pagination.rb +39 -0
- data/app/controllers/sincerely/concerns/periods.rb +40 -0
- data/app/controllers/sincerely/dashboard_controller.rb +30 -88
- data/app/controllers/sincerely/delivery_events_controller.rb +13 -21
- data/app/controllers/sincerely/engagement_events_controller.rb +13 -22
- data/app/controllers/sincerely/notifications_controller.rb +68 -49
- data/app/controllers/sincerely/send_controller.rb +82 -67
- data/app/controllers/sincerely/templates_controller.rb +37 -8
- data/app/helpers/sincerely/application_helper.rb +22 -0
- data/app/helpers/sincerely/chart_helper.rb +79 -0
- data/app/views/layouts/sincerely/application.html.erb +24 -528
- data/app/views/sincerely/dashboard/index.html.erb +25 -256
- data/app/views/sincerely/delivery_events/index.html.erb +6 -38
- data/app/views/sincerely/engagement_events/index.html.erb +6 -38
- data/app/views/sincerely/notifications/index.html.erb +7 -23
- data/app/views/sincerely/notifications/show.html.erb +4 -4
- data/app/views/sincerely/send/new.html.erb +8 -522
- data/app/views/sincerely/shared/_event.html.erb +21 -0
- data/app/views/sincerely/shared/_line_chart.html.erb +38 -0
- data/app/views/sincerely/shared/_notification_type_filter.html.erb +9 -0
- data/app/views/sincerely/shared/_status_option.html.erb +1 -0
- data/app/views/sincerely/shared/_template_option.html.erb +3 -0
- data/app/views/sincerely/templates/_form.html.erb +8 -107
- data/app/views/sincerely/templates/edit.html.erb +2 -2
- data/app/views/sincerely/templates/index.html.erb +7 -15
- data/app/views/sincerely/templates/new.html.erb +2 -2
- data/app/views/sincerely/templates/preview.html.erb +7 -26
- data/app/views/sincerely/templates/show.html.erb +3 -3
- data/lib/config/sincerely_config.rb +2 -2
- data/lib/sincerely/delivery_systems/email_aws_ses.rb +2 -2
- data/lib/sincerely/engine.rb +4 -0
- data/lib/sincerely/mixins/notification_model.rb +5 -1
- data/lib/sincerely/services/process_delivery_event.rb +9 -7
- data/lib/sincerely/templates/notification_template.rb +1 -1
- data/lib/sincerely/version.rb +1 -1
- metadata +52 -4
- data/sincerely.gemspec +0 -37
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6404221d2f3d54bcc784af23015c254f5b6a7cc6f4e6f625b648cba7ce2370f2
|
|
4
|
+
data.tar.gz: f5c4bbee5ad0e3fd813e7995f4b33fc355b129b882420c386422a46508d40c12
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 @@
|
|
|
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 @@
|
|
|
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 @@
|
|
|
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,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
|
+
})();
|