Diff -- /var/www/vhosts/3dshawn.com/affiliate.3dshawn.com/admin_email_announcements.cgi

O Operator
Diff

/var/www/vhosts/3dshawn.com/affiliate.3dshawn.com/admin_email_announcements.cgi

added on local at 2026-07-11 18:38:31

Added
+879
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to 09abe5629d2f
Restore this content →
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.
1#!/usr/bin/perl
2#======================================================================
3# AffSoft -- /admin_email_announcements.cgi
4#
5# Announcements tab of the Email Setup section. Super-admin only.
6#
7# GET list rows + show composer (?edit=N to edit one)
8# POST act=save upsert an announcement row (status flows below)
9# POST act=publish_now flip status to published + fire push + queue blast
10# POST act=schedule set scheduled_send_at + status=scheduled
11# POST act=send_test enqueue a single test email to the admin
12# POST act=delete delete (with hard-confirm modal in the UI)
13#
14# Status lifecycle:
15# draft - saved, not visible to anyone
16# scheduled - published_at NULL, scheduled_send_at IN THE FUTURE
17# (the _messaging_email_blast.pl worker promotes to
18# 'published' when its scheduled time arrives)
19# published - visible to the audience (published_at NOT NULL)
20# sent - published AND email_blast_status='sent' (terminal)
21#======================================================================
22use strict;
23use warnings;
24
25use lib '/var/www/vhosts/3dshawn.com/affiliate.3dshawn.com';
26use CGI;
27use MODS::DBConnect;
28use MODS::AffSoft::Wrapper;
29use MODS::AffSoft::Config;
30use MODS::Login;
31use MODS::AffSoft::Messaging;
32use MODS::AffSoft::EmailAdminTabs;
33
34my $q = CGI->new;
35my $form = $q->Vars;
36my $wrap = MODS::AffSoft::Wrapper->new;
37my $db = MODS::DBConnect->new;
38my $cfg = MODS::AffSoft::Config->new;
39my $msg = MODS::AffSoft::Messaging->new;
40my $DB = $cfg->settings('database_name');
41
42my $auth = MODS::Login->new;
43my $u = $auth->login_verify;
44unless ($u) { print "Status: 302 Found\nLocation: /login.cgi\n\n"; exit; }
45require MODS::AffSoft::Permissions;
46MODS::AffSoft::Permissions->new->require_super_admin($u);
47my $uid = $u->{user_id} + 0;
48
49my $dbh = $db->db_connect;
50my $act = $form->{act} || '';
51my $flash = '';
52my $flash_kind = 'ok';
53
54sub _h {
55 my $s = shift; $s = '' unless defined $s;
56 $s =~ s/&/&amp;/g; $s =~ s/</&lt;/g; $s =~ s/>/&gt;/g;
57 $s =~ s/"/&quot;/g; $s =~ s/'/&#39;/g; return $s;
58}
59
60# Parse "YYYY-MM-DDTHH:MM" datetime-local into mysql TIMESTAMP string.
61sub _parse_datetime_local {
62 my $v = shift; return '' unless defined $v && length $v;
63 if ($v =~ /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?$/) {
64 my $sec = $6 || '00';
65 return "$1-$2-$3 $4:$5:$sec";
66 }
67 return '';
68}
69
70# ---- POST handlers --------------------------------------------------
71if (($ENV{REQUEST_METHOD} || '') eq 'POST') {
72 if ($act eq 'save' || $act eq 'publish_now' || $act eq 'schedule') {
73 my $aid = ($form->{announcement_id} || 0) + 0;
74 my $title = $form->{title} || '';
75 my $body = $form->{body} || '';
76 my $aud = $form->{audience_kind} || 'all';
77 my $send_e = $form->{send_email} ? 1 : 0;
78 my $sched_raw = $form->{scheduled_send_at} || '';
79 my $sched_sql = _parse_datetime_local($sched_raw);
80
81 # Audience filter: only program_affiliates uses one today. Bundle
82 # the chosen program_id into a tiny JSON blob. Other audience
83 # kinds clear the filter so a leftover program_id from a prior
84 # save doesn't survive.
85 my $aud_filter_json = '';
86 if ($aud eq 'program_affiliates') {
87 my $pid = ($form->{audience_program_id} || 0) + 0;
88 $aud_filter_json = $pid ? qq~{"program_id":$pid}~ : '';
89 }
90
91 unless (length $title && length $body) {
92 $flash_kind = 'err';
93 $flash = 'Title and body are required.';
94 } elsif ($aud eq 'program_affiliates' && !length $aud_filter_json) {
95 $flash_kind = 'err';
96 $flash = 'Pick a program when audience is "Affiliates of a specific program".';
97 } elsif (!$aid) {
98 # New row
99 my $publish_now = ($act eq 'publish_now') ? 1 : 0;
100 my ($id, $err) = $msg->create_announcement($db, $dbh, $DB, {
101 scope => 'platform',
102 posted_by => $uid,
103 title => $title,
104 body => $body,
105 audience_kind => $aud,
106 audience_filter_json => $aud_filter_json,
107 send_email => $send_e,
108 publish_now => $publish_now,
109 scheduled_send_at => ($act eq 'schedule' && $sched_sql) ? $sched_sql : undef,
110 });
111 if ($id) {
112 if ($publish_now) {
113 my $a = $msg->load_announcement($db, $dbh, $DB, $id);
114 my @uids = $msg->resolve_announcement_recipients($db, $dbh, $DB, $a);
115 $msg->push_notify_users($db, $dbh, $DB, \@uids);
116 $msg->queue_announcement_blast($db, $dbh, $DB, $a) if $send_e;
117 }
118 $aid = $id;
119 $flash = $publish_now ? 'Published.' : ($act eq 'schedule' ? 'Scheduled.' : 'Draft saved.');
120 } else {
121 $flash_kind = 'err';
122 $flash = "Save failed: " . ($err || 'unknown');
123 }
124 } else {
125 # Update existing
126 my %fields = (
127 title => $title, body => $body,
128 audience_kind => $aud, send_email => $send_e,
129 audience_filter_json => $aud_filter_json,
130 );
131 if ($act eq 'publish_now') {
132 $fields{status} = 'published';
133 } elsif ($act eq 'schedule' && $sched_sql) {
134 $fields{status} = 'scheduled';
135 $fields{scheduled_send_at} = $sched_sql;
136 } elsif ($act eq 'save') {
137 # leave status alone unless it was scheduled -> bump back to draft
138 # when the scheduled-time field is cleared.
139 if (!length $sched_raw) {
140 $fields{scheduled_send_at} = '';
141 }
142 }
143 $msg->update_announcement($db, $dbh, $DB, $aid, \%fields);
144
145 if ($act eq 'publish_now') {
146 # Flip publish + fire push + blast
147 $db->db_readwrite($dbh, qq~
148 UPDATE `${DB}`.announcements
149 SET published_at = COALESCE(published_at, NOW())
150 WHERE id='$aid' LIMIT 1
151 ~, $0, __LINE__);
152 my $a = $msg->load_announcement($db, $dbh, $DB, $aid);
153 my @uids = $msg->resolve_announcement_recipients($db, $dbh, $DB, $a);
154 $msg->push_notify_users($db, $dbh, $DB, \@uids);
155 $msg->queue_announcement_blast($db, $dbh, $DB, $a) if $send_e;
156 $flash = 'Published.';
157 } elsif ($act eq 'schedule') {
158 $flash = $sched_sql ? "Scheduled for $sched_sql." : 'Schedule field was empty.';
159 $flash_kind = $sched_sql ? 'ok' : 'err';
160 } else {
161 $flash = 'Saved.';
162 }
163 }
164 }
165 elsif ($act eq 'duplicate') {
166 # Clone an existing row into a fresh draft. The new row keeps
167 # the source's title/body/audience but starts unpublished and
168 # unscheduled so the admin can re-time it.
169 my $src_id = ($form->{announcement_id} || 0) + 0;
170 if ($src_id) {
171 my $src = $msg->load_announcement($db, $dbh, $DB, $src_id);
172 if ($src) {
173 my $clone_title = $src->{title} || '';
174 $clone_title = "Copy of $clone_title" unless $clone_title =~ /^Copy of /;
175 my ($new_id, $err) = $msg->create_announcement($db, $dbh, $DB, {
176 scope => $src->{scope} || 'platform',
177 posted_by => $uid,
178 title => $clone_title,
179 body => $src->{body} || '',
180 audience_kind => $src->{audience_kind} || 'all',
181 audience_filter_json => $src->{audience_filter_json} || '',
182 send_email => $src->{send_email} ? 1 : 0,
183 publish_now => 0,
184 });
185 if ($new_id) {
186 $flash = "Duplicated as draft #$new_id.";
187 print "Status: 302 Found\r\nLocation: /admin_email_announcements.cgi?edit=$new_id&flash=duped\r\n\r\n";
188 $db->db_disconnect($dbh);
189 exit;
190 } else {
191 $flash_kind = 'err'; $flash = 'Duplicate failed: ' . ($err || 'unknown');
192 }
193 } else {
194 $flash_kind = 'err'; $flash = 'Source announcement not found.';
195 }
196 }
197 }
198 elsif ($act eq 'cancel_schedule') {
199 # Scheduled -> draft. Clears scheduled_send_at and leaves
200 # published_at NULL. The blast worker no longer picks it up.
201 my $aid = ($form->{announcement_id} || 0) + 0;
202 if ($aid) {
203 $msg->update_announcement($db, $dbh, $DB, $aid, {
204 status => 'draft',
205 scheduled_send_at => '',
206 });
207 $flash = "Schedule cancelled. Row #$aid is now a draft.";
208 }
209 }
210 elsif ($act eq 'publish_now_from_list') {
211 # Row-action shortcut: same as the form's publish_now but for a
212 # specific id without re-rendering the composer first.
213 my $aid = ($form->{announcement_id} || 0) + 0;
214 if ($aid) {
215 $msg->update_announcement($db, $dbh, $DB, $aid, { status => 'published' });
216 $db->db_readwrite($dbh, qq~
217 UPDATE `${DB}`.announcements
218 SET published_at = COALESCE(published_at, NOW()),
219 scheduled_send_at = NULL
220 WHERE id='$aid' LIMIT 1
221 ~, $0, __LINE__);
222 my $a = $msg->load_announcement($db, $dbh, $DB, $aid);
223 if ($a) {
224 my @uids = $msg->resolve_announcement_recipients($db, $dbh, $DB, $a);
225 $msg->push_notify_users($db, $dbh, $DB, \@uids);
226 $msg->queue_announcement_blast($db, $dbh, $DB, $a) if $a->{send_email};
227 $flash = "Published row #$aid. " . scalar(@uids) . ' recipients resolved.';
228 } else {
229 $flash_kind = 'err'; $flash = 'Row vanished mid-publish.';
230 }
231 }
232 }
233 elsif ($act eq 'send_test') {
234 my $aid = ($form->{announcement_id} || 0) + 0;
235 my $to = $form->{test_to} || $u->{email} || '';
236 if ($aid && $to =~ /\@/) {
237 my $a = $msg->load_announcement($db, $dbh, $DB, $aid);
238 if ($a) {
239 my $title = $a->{title} || '';
240 my $body = $a->{body} || '';
241 my $url = "https://affiliate.3dshawn.com/inbox.cgi?ann=$aid";
242 # body is sanitized WYSIWYG HTML; emit verbatim.
243 # Build a quick text version for the multipart/text leg.
244 my $body_plain = $body;
245 $body_plain =~ s{<br\s*/?>}{\n}gi;
246 $body_plain =~ s{</p>}{\n\n}gi;
247 $body_plain =~ s{</(h[1-6]|li|div|blockquote)>}{\n}gi;
248 $body_plain =~ s{<[^>]+>}{}g;
249 my $text = "(TEST) $title\n\n$body_plain\n\nView: $url\n";
250 my $html = qq~<p style="background:rgba(251,191,36,.15);padding:6px 10px;border-radius:4px;font-size:12px;color:#fbbf24"><strong>TEST DELIVERY</strong> &middot; this email was sent from /admin_email_announcements.cgi to verify the blast pipeline.</p><h2>~ . _h($title) . qq~</h2><div>~ . $body . qq~</div><p><a href="$url">View in AffSoft</a></p>~;
251 $msg->enqueue_email($db, $dbh, $DB, {
252 recipient_email => $to,
253 subject => "[TEST] $title",
254 body_text => $text,
255 body_html => $html,
256 source_tag => 'announcement_test',
257 source_ref_id => $aid,
258 });
259 $flash = "Test enqueued to $to (worker drains every 5 min).";
260 } else {
261 $flash_kind = 'err'; $flash = 'Announcement not found.';
262 }
263 } else {
264 $flash_kind = 'err'; $flash = 'Need an announcement and a recipient email.';
265 }
266 }
267 elsif ($act eq 'delete') {
268 my $aid = ($form->{announcement_id} || 0) + 0;
269 if ($aid) {
270 $db->db_readwrite($dbh, qq~DELETE FROM `${DB}`.announcement_reads WHERE announcement_id='$aid'~, $0, __LINE__);
271 $db->db_readwrite($dbh, qq~DELETE FROM `${DB}`.announcements WHERE id='$aid' LIMIT 1~, $0, __LINE__);
272 $flash = 'Deleted.';
273 }
274 }
275}
276
277# ---- GET data -------------------------------------------------------
278my $edit_id = ($form->{edit} || 0) + 0;
279my $compose = $form->{compose} ? 1 : 0;
280
281# Filter chip: All / Drafts / Scheduled / Published. The 'Published'
282# chip rolls 'sent' rows in (they're terminal published rows). Worker
283# stamps email_blast_status='sent' so the rollup is exact.
284my $filter = lc($form->{filter} || 'all');
285$filter = 'all' unless $filter =~ /^(all|drafts|scheduled|published)$/;
286
287# Detect status column for list query
288my $has_status_col = $msg->_announcement_has_status($db, $dbh, $DB);
289my $status_select = $has_status_col
290 ? 'a.status, a.scheduled_send_at, UNIX_TIMESTAMP(a.scheduled_send_at) AS scheduled_send_at_epoch,'
291 : "'(legacy)' AS status, NULL AS scheduled_send_at, NULL AS scheduled_send_at_epoch,";
292
293my @rows = $db->db_readwrite_multiple($dbh, qq~
294 SELECT a.id, a.title, a.body, a.audience_kind, a.audience_filter_json,
295 a.send_email,
296 a.email_blast_status, a.published_at, a.created_at,
297 UNIX_TIMESTAMP(a.published_at) AS published_at_epoch,
298 UNIX_TIMESTAMP(a.created_at) AS created_at_epoch,
299 $status_select
300 u.display_name AS poster_name,
301 (SELECT COUNT(*) FROM `${DB}`.announcement_reads WHERE announcement_id=a.id) AS read_count
302 FROM `${DB}`.announcements a
303 LEFT JOIN `${DB}`.users u ON u.id=a.posted_by_user_id
304 WHERE a.scope='platform'
305 ORDER BY a.id DESC
306 LIMIT 200
307~, $0, __LINE__);
308
309# Programs for the audience picker. Super-admin sees every active program
310# (no draft/paused/closed -- they're not yet visible to affiliates) so
311# the picker stays tight. Borrowed pattern: merchant_announcements.cgi
312# uses WHERE merchant_user_id=$uid ORDER BY name; admin uses
313# status='active' ORDER BY name.
314my @audience_programs = $db->db_readwrite_multiple($dbh, qq~
315 SELECT id, name FROM `${DB}`.affiliate_programs
316 WHERE status='active'
317 ORDER BY name
318~, $0, __LINE__);
319# Name lookup for the list-page "Affiliates of <Program Name>" label.
320# Loads EVERY program (even paused/closed) so old rows render their
321# program name; ~200 row LIMIT keeps this trivial.
322my %program_name_by_id;
323for my $p ($db->db_readwrite_multiple($dbh, qq~
324 SELECT id, name FROM `${DB}`.affiliate_programs LIMIT 500
325~, $0, __LINE__)) {
326 $program_name_by_id{$p->{id}} = $p->{name};
327}
328
329my $edit_row;
330if ($edit_id) {
331 ($edit_row) = grep { $_->{id} == $edit_id } @rows;
332 $edit_row ||= $msg->load_announcement($db, $dbh, $DB, $edit_id);
333}
334
335$db->db_disconnect($dbh);
336
337# ---- Render ---------------------------------------------------------
338print "Content-Type: text/html; charset=utf-8\r\nCache-Control: no-store\r\n\r\n";
339
340my $flash_html = '';
341if (length $flash) {
342 my ($bg, $bd, $fg) = $flash_kind eq 'err'
343 ? ('rgba(239,68,68,.12)', 'rgba(239,68,68,.3)', '#fca5a5')
344 : ('rgba(34,197,94,.12)', 'rgba(34,197,94,.3)', '#86efac');
345 $flash_html = qq~<div style="padding:10px 14px;background:$bg;border:1px solid $bd;border-radius:8px;color:$fg;font-size:13px;margin-bottom:18px">~ . _h($flash) . qq~</div>~;
346}
347
348my $tabs_html = MODS::AffSoft::EmailAdminTabs->render('announcements');
349
350my %status_color = (
351 draft => '#94a3b8',
352 scheduled => '#fbbf24',
353 published => '#5aa9ff',
354 sent => '#86efac',
355);
356
357# Build the chip strip. Active chip gets a brighter background.
358my @chip_defs = (
359 { k => 'all', label => 'All' },
360 { k => 'drafts', label => 'Drafts' },
361 { k => 'scheduled', label => 'Scheduled' },
362 { k => 'published', label => 'Published' },
363);
364my $filter_chips_html = '';
365foreach my $cd (@chip_defs) {
366 my $is_a = ($filter eq $cd->{k}) ? 1 : 0;
367 my $bg = $is_a ? '#5aa9ff' : 'transparent';
368 my $fg = $is_a ? '#fff' : '#cbd5e1';
369 my $bd = $is_a ? '#5aa9ff' : 'rgba(255,255,255,.12)';
370 $filter_chips_html .= qq~<a href="/admin_email_announcements.cgi?filter=$cd->{k}" style="padding:5px 14px;border-radius:999px;font-size:12px;background:$bg;color:$fg;border:1px solid $bd;text-decoration:none;font-weight:600;letter-spacing:.02em">$cd->{label}</a>~;
371}
372
373# Friendly audience label for the list page. Falls back to the raw enum
374# if it's an unknown kind so a future audience type at least shows up
375# instead of going blank. NULL filter / missing program_id on a
376# program_affiliates row renders as "Affiliates of (unknown program)".
377sub _audience_label {
378 my ($kind, $filter_json, $name_lookup) = @_;
379 $kind ||= 'all';
380 if ($kind eq 'all') { return 'All'; }
381 elsif ($kind eq 'affiliates') { return 'Affiliates'; }
382 elsif ($kind eq 'merchants') { return 'Merchants'; }
383 elsif ($kind eq 'program_affiliates') {
384 my $pid = 0;
385 if (defined $filter_json && $filter_json =~ /"program_id"\s*:\s*(\d+)/) {
386 $pid = $1 + 0;
387 }
388 my $pname = ($pid && $name_lookup && $name_lookup->{$pid})
389 ? $name_lookup->{$pid} : '(unknown program)';
390 return "Affiliates of $pname";
391 }
392 return $kind;
393}
394
395my $rows_html = '';
396# Apply the filter chip BEFORE counting; we still want a "no rows"
397# message that's accurate for the active filter.
398my @filtered;
399for my $r (@rows) {
400 my $rstatus = lc($r->{status} || 'draft');
401 $rstatus = 'sent' if ($rstatus eq 'published' && ($r->{email_blast_status}||'') eq 'sent');
402 next if $filter eq 'drafts' && $rstatus ne 'draft';
403 next if $filter eq 'scheduled' && $rstatus ne 'scheduled';
404 next if $filter eq 'published' && $rstatus ne 'published' && $rstatus ne 'sent';
405 push @filtered, $r;
406}
407if (!@filtered) {
408 my $msg_txt = ($filter eq 'all')
409 ? 'No announcements yet -- compose your first one above.'
410 : "No $filter announcements.";
411 $rows_html = qq~<tr><td colspan="7" style="opacity:.6;padding:20px;text-align:center;color:#94a3b8">$msg_txt</td></tr>~;
412}
413for my $r (@filtered) {
414 my $tt = _h($r->{title});
415 my $aud = _h(_audience_label($r->{audience_kind}, $r->{audience_filter_json}, \%program_name_by_id));
416 my $pub = _h($r->{published_at} || '(not yet)');
417 my $pub_ep = ($r->{published_at_epoch} || 0) + 0;
418 my $sched = _h($r->{scheduled_send_at} || '');
419 my $sched_ep = ($r->{scheduled_send_at_epoch} || 0) + 0;
420 my $rstatus = lc($r->{status} || 'draft');
421 # Promote to 'sent' if published + blast complete (defensive --
422 # the worker should already have done this).
423 $rstatus = 'sent' if ($rstatus eq 'published' && ($r->{email_blast_status}||'') eq 'sent');
424 my $color = $status_color{$rstatus} || '#94a3b8';
425 my $blast = $r->{send_email}
426 ? _h($r->{email_blast_status})
427 : '(no email)';
428 my $reads = $r->{read_count} || 0;
429 my $poster= _h($r->{poster_name} || '');
430 # Per-row action buttons. Delete only for true drafts (never wipe a
431 # published or scheduled row from the list). Publish-now for draft
432 # OR scheduled (a manual override of the schedule). Cancel-schedule
433 # only when the row is actually scheduled.
434 my $publish_now_btn = '';
435 my $cancel_sched_btn = '';
436 my $delete_btn = '';
437 if ($rstatus eq 'draft' || $rstatus eq 'scheduled') {
438 $publish_now_btn = qq~<form action="/admin_email_announcements.cgi" method="POST" style="display:inline;margin-right:6px" class="js-pub-now-form" data-title="$tt"><input type="hidden" name="act" value="publish_now_from_list"><input type="hidden" name="announcement_id" value="$r->{id}"><button type="submit" style="background:#5aa9ff;color:#fff;border:none;border-radius:4px;padding:4px 10px;font-size:11px;cursor:pointer;font-weight:600">Publish now</button></form>~;
439 }
440 if ($rstatus eq 'scheduled') {
441 $cancel_sched_btn = qq~<form action="/admin_email_announcements.cgi" method="POST" style="display:inline;margin-right:6px"><input type="hidden" name="act" value="cancel_schedule"><input type="hidden" name="announcement_id" value="$r->{id}"><button type="submit" style="background:transparent;color:#fbbf24;border:1px solid rgba(251,191,36,.35);border-radius:4px;padding:4px 10px;font-size:11px;cursor:pointer;font-weight:600">Cancel schedule</button></form>~;
442 }
443 if ($rstatus eq 'draft') {
444 $delete_btn = qq~<button type="button" class="js-del-ann" data-id="$r->{id}" data-title="$tt" style="background:rgba(239,68,68,.10);color:#fca5a5;border:1px solid rgba(239,68,68,.35);border-radius:4px;padding:4px 10px;font-size:11px;cursor:pointer;font-weight:600">Delete</button>~;
445 }
446 $rows_html .= qq~<tr style="border-top:1px solid rgba(255,255,255,.05)">
447 <td style="padding:10px 12px">
448 <div style="font-weight:600;color:#e2e8f0">$tt</div>
449 <div style="font-size:11px;color:#94a3b8;margin-top:2px">by $poster &middot; aud: $aud</div>
450 </td>
451 <td style="padding:10px 12px"><span style="font-size:11px;color:$color;text-transform:uppercase;letter-spacing:.05em;font-weight:700">$rstatus</span></td>
452 <td style="padding:10px 12px;font-size:12px;color:#cbd5e1"><span class="ts" data-ts="$sched_ep" data-fmt="datetime">$sched</span></td>
453 <td style="padding:10px 12px;font-size:12px;color:#cbd5e1"><span class="ts" data-ts="$pub_ep" data-fmt="datetime">$pub</span></td>
454 <td style="padding:10px 12px;font-size:12px;color:#cbd5e1">$blast</td>
455 <td style="padding:10px 12px;font-size:12px;color:#cbd5e1">$reads</td>
456 <td style="padding:10px 12px;font-size:12px;white-space:nowrap">
457 <a href="/admin_email_announcements.cgi?edit=$r->{id}" style="color:#5aa9ff;text-decoration:none;margin-right:8px">Edit</a>
458 <form action="/admin_email_announcements.cgi" method="POST" style="display:inline;margin-right:6px">
459 <input type="hidden" name="act" value="duplicate">
460 <input type="hidden" name="announcement_id" value="$r->{id}">
461 <button type="submit" style="background:transparent;color:#a78bfa;border:1px solid rgba(167,139,250,.35);border-radius:4px;padding:4px 10px;font-size:11px;cursor:pointer;font-weight:600">Duplicate</button>
462 </form>
463$publish_now_btn
464$cancel_sched_btn
465$delete_btn
466 </td>
467 </tr>~;
468}
469
470# Composer (new OR edit)
471my ($e_id, $e_title, $e_body, $e_aud, $e_send, $e_sched, $e_status, $e_pid) =
472 (0, '', '', 'all', 0, '', 'draft', 0);
473if ($edit_row) {
474 $e_id = $edit_row->{id};
475 $e_title = $edit_row->{title} || '';
476 $e_body = $edit_row->{body} || '';
477 $e_aud = $edit_row->{audience_kind} || 'all';
478 $e_send = $edit_row->{send_email} ? 1 : 0;
479 $e_sched = $edit_row->{scheduled_send_at} || '';
480 $e_status = $edit_row->{status} || ($edit_row->{published_at} ? 'published' : 'draft');
481 # Pre-select the program when editing a program_affiliates row.
482 if (($edit_row->{audience_filter_json} || '') =~ /"program_id"\s*:\s*(\d+)/) {
483 $e_pid = $1 + 0;
484 }
485}
486$e_sched =~ s/ /T/; # YYYY-MM-DD HH:MM:SS -> YYYY-MM-DDTHH:MM:SS
487$e_sched =~ s/(:\d{2}):\d{2}$/$1/; # strip seconds for datetime-local
488
489my $tt_h = _h($e_title);
490my $bd_h = _h($e_body);
491my $sch_h = _h($e_sched);
492my $stat_h = _h($e_status);
493my $send_ck = $e_send ? 'checked' : '';
494# Friendly labels in the audience dropdown -- raw enum was unhelpful.
495my @aud_choices = (
496 { k => 'all', label => 'All users' },
497 { k => 'affiliates', label => 'All affiliates' },
498 { k => 'merchants', label => 'All merchants' },
499 { k => 'program_affiliates', label => 'Affiliates of a specific program' },
500);
501my $aud_opts = '';
502foreach my $c (@aud_choices) {
503 my $sel = ($c->{k} eq $e_aud) ? ' selected' : '';
504 $aud_opts .= qq~<option value="$c->{k}"$sel>~ . _h($c->{label}) . qq~</option>~;
505}
506# Program <select> options. If the edit row references a program that
507# isn't in the active list (paused/closed), prepend a one-off option so
508# the edit form doesn't silently lose the selection.
509my $prog_opts = '<option value="">-- pick a program --</option>';
510my $seen_e_pid = 0;
511foreach my $p (@audience_programs) {
512 my $sel = ($p->{id} == $e_pid) ? ' selected' : '';
513 $seen_e_pid = 1 if $p->{id} == $e_pid;
514 $prog_opts .= qq~<option value="$p->{id}"$sel>~ . _h($p->{name}) . qq~</option>~;
515}
516if ($e_pid && !$seen_e_pid && $program_name_by_id{$e_pid}) {
517 $prog_opts .= qq~<option value="$e_pid" selected>~ . _h($program_name_by_id{$e_pid}) . qq~ (inactive)</option>~;
518}
519my $prog_picker_display = ($e_aud eq 'program_affiliates') ? 'block' : 'none';
520
521my $composer_heading = $e_id ? "Edit announcement #$e_id" : "New announcement";
522my $cancel_link = $e_id ? '<a href="/admin_email_announcements.cgi" style="color:#94a3b8;font-size:12px;margin-left:14px">Cancel edit</a>' : '';
523my $test_email = _h($u->{email} || '');
524
525my $body = qq~
526<div class="page-pad">
527 <h1 style="margin:0 0 6px;font-size:28px;color:#fff">Email Setup</h1>
528 <p style="color:#94a3b8;margin:0 0 18px;font-size:14px">Templates, schedules, controls, and announcements.</p>
529 $tabs_html
530 $flash_html
531
532 <div style="background:rgba(20,20,30,.4);border:1px solid rgba(255,255,255,.08);border-radius:12px;padding:20px;margin-bottom:32px">
533 <h2 style="margin:0 0 14px;font-size:18px;color:#fff">$composer_heading <span style="font-size:12px;color:$status_color{$stat_h};font-weight:600;text-transform:uppercase;letter-spacing:.05em;margin-left:8px">$stat_h</span>$cancel_link</h2>
534 <form method="POST" action="/admin_email_announcements.cgi" id="ann-form">
535 <input type="hidden" name="announcement_id" value="$e_id">
536 <label style="display:block;font-size:12px;text-transform:uppercase;letter-spacing:.05em;color:#cbd5e1;margin-bottom:6px">Title</label>
537 <input class="input" type="text" name="title" value="$tt_h" maxlength="200" required
538 style="width:100%;background:#0e131c;color:#fff;border:1px solid rgba(255,255,255,.10);border-radius:8px;padding:10px;margin-bottom:14px;font-size:14px">
539 <label style="display:block;font-size:12px;text-transform:uppercase;letter-spacing:.05em;color:#cbd5e1;margin-bottom:6px">Body</label>
540 <div class="wyse" id="wyse" style="margin-bottom:14px">
541 <div class="wyse-toolbar">
542 <button type="button" class="wbtn" data-cmd="bold" title="Bold (Ctrl+B)"><b>B</b></button>
543 <button type="button" class="wbtn" data-cmd="italic" title="Italic (Ctrl+I)"><i>I</i></button>
544 <button type="button" class="wbtn" data-cmd="underline" title="Underline"><u>U</u></button>
545 <button type="button" class="wbtn" data-cmd="strikeThrough" title="Strikethrough"><s>S</s></button>
546 <span class="wsep"></span>
547 <button type="button" class="wbtn wbtn-text" data-block="h1" title="Heading 1">H1</button>
548 <button type="button" class="wbtn wbtn-text" data-block="h2" title="Heading 2">H2</button>
549 <button type="button" class="wbtn wbtn-text" data-block="h3" title="Heading 3">H3</button>
550 <button type="button" class="wbtn wbtn-text" data-block="p" title="Paragraph">P</button>
551 <span class="wsep"></span>
552 <button type="button" class="wbtn" data-cmd="insertUnorderedList" title="Bulleted list">&bull;</button>
553 <button type="button" class="wbtn" data-cmd="insertOrderedList" title="Numbered list">1.</button>
554 <button type="button" class="wbtn wbtn-text" data-block="blockquote" title="Block quote">&ldquo;</button>
555 <button type="button" class="wbtn wbtn-text" data-action="code" title="Inline code">&lt;/&gt;</button>
556 <span class="wsep"></span>
557 <button type="button" class="wbtn wbtn-text" data-action="link" title="Insert link">Link</button>
558 <button type="button" class="wbtn" data-cmd="unlink" title="Remove link">&#128279;</button>
559 <button type="button" class="wbtn wbtn-text" data-cmd="insertHorizontalRule" title="Insert horizontal rule">&mdash;</button>
560 <span class="wsep" style="margin-left:auto"></span>
561 <button type="button" class="wbtn wbtn-text wbtn-mode" id="wyse-mode" title="Toggle between rendered HTML and raw source">Source &#8644;</button>
562 </div>
563 <div class="wyse-rendered" id="wyse-rendered" contenteditable="true" spellcheck="true"></div>
564 <textarea class="wyse-source" id="wyse-source" style="display:none">$bd_h</textarea>
565 </div>
566 <input type="hidden" name="body" id="wyse-hidden">
567 <style>
568 .wyse { border:1px solid rgba(255,255,255,.10); border-radius:10px; overflow:hidden; background:#0f1420; }
569 .wyse-toolbar { display:flex; flex-wrap:wrap; gap:2px; padding:8px 10px; background:#171b2a; border-bottom:1px solid rgba(255,255,255,.10); align-items:center; }
570 .wyse .wbtn { background:transparent; border:1px solid transparent; color:#cbd5e1; font-size:13px; min-width:32px; height:30px; padding:0 8px; border-radius:6px; cursor:pointer; display:inline-flex; align-items:center; justify-content:center; line-height:1; font-family:inherit; }
571 .wyse .wbtn:hover { background:rgba(255,255,255,.06); color:#fff; }
572 .wyse .wbtn.is-active { background:rgba(90,169,255,.20); color:#bfdbff; border-color:rgba(90,169,255,.35); }
573 .wyse .wbtn-text { font-size:12px; font-weight:600; }
574 .wyse .wsep { width:1px; height:18px; background:rgba(255,255,255,.10); margin:0 6px; }
575 .wyse .wbtn-mode { background:rgba(139,92,246,.15); color:#c4b5fd; border-color:rgba(139,92,246,.30); }
576 .wyse .wbtn-mode:hover { background:rgba(139,92,246,.25); }
577 .wyse-rendered { min-height:240px; padding:16px 20px; color:#e2e8f0; font-size:14px; line-height:1.6; outline:none; background:#0b0f1a; }
578 .wyse-rendered:focus { background:#0b0f1a; }
579 .wyse-rendered h1 { font-size:22px; margin:.4em 0 .3em; color:#f1f5f9; }
580 .wyse-rendered h2 { font-size:18px; margin:.5em 0 .3em; color:#f1f5f9; }
581 .wyse-rendered h3 { font-size:16px; margin:.5em 0 .3em; color:#f1f5f9; }
582 .wyse-rendered p { margin:.5em 0; }
583 .wyse-rendered ul, .wyse-rendered ol { padding-left:1.6em; margin:.5em 0; }
584 .wyse-rendered a { color:#5aa9ff; text-decoration:underline; }
585 .wyse-rendered code { background:rgba(255,255,255,.06); padding:1px 6px; border-radius:4px; font-family:ui-monospace,Consolas,Menlo,monospace; font-size:12.5px; }
586 .wyse-rendered blockquote { border-left:3px solid rgba(255,255,255,.20); margin:.8em 0; padding:.2em 1em; color:#cbd5e1; }
587 .wyse-rendered hr { border:0; border-top:1px solid rgba(255,255,255,.15); margin:1em 0; }
588 .wyse-source { width:100%; min-height:240px; padding:14px 18px; background:#070a13; color:#cbd5e1; border:0; outline:none; font-family:ui-monospace,Consolas,Menlo,monospace; font-size:12.5px; resize:vertical; box-sizing:border-box; }
589 </style>
590 <script>
591 (function () {
592 var root = document.getElementById('wyse');
593 var rendered= document.getElementById('wyse-rendered');
594 var source = document.getElementById('wyse-source');
595 var hidden = document.getElementById('wyse-hidden');
596 var modeBtn = document.getElementById('wyse-mode');
597 if (!root || !rendered || !source) return;
598
599 var mode = 'rendered';
600
601 // Hydrate the rendered pane from the source (= existing body HTML)
602 // on first load. Legacy plain-text drafts also flow through here
603 // -- innerHTML is still the right call because we want angle-
604 // brackets in old plain text to render as literal characters
605 // (the textarea already contains escaped entities).
606 rendered.innerHTML = source.value;
607 hidden.value = source.value;
608
609 function syncHidden() {
610 if (mode === 'rendered') {
611 source.value = rendered.innerHTML;
612 } else {
613 rendered.innerHTML = source.value;
614 }
615 hidden.value = source.value;
616 }
617
618 root.querySelectorAll('.wbtn[data-cmd]').forEach(function (b) {
619 b.addEventListener('click', function (ev) {
620 ev.preventDefault();
621 if (mode !== 'rendered') return;
622 rendered.focus();
623 document.execCommand(b.getAttribute('data-cmd'), false, null);
624 syncHidden();
625 });
626 });
627
628 root.querySelectorAll('.wbtn[data-block]').forEach(function (b) {
629 b.addEventListener('click', function (ev) {
630 ev.preventDefault();
631 if (mode !== 'rendered') return;
632 rendered.focus();
633 document.execCommand('formatBlock', false, b.getAttribute('data-block'));
634 syncHidden();
635 });
636 });
637
638 root.querySelectorAll('.wbtn[data-action]').forEach(function (b) {
639 b.addEventListener('click', function (ev) {
640 ev.preventDefault();
641 if (mode !== 'rendered') return;
642 rendered.focus();
643 var action = b.getAttribute('data-action');
644 if (action === 'link') {
645 var url = prompt('URL?', 'https://');
646 if (url) document.execCommand('createLink', false, url);
647 } else if (action === 'code') {
648 var sel = window.getSelection();
649 if (sel && sel.rangeCount && !sel.isCollapsed) {
650 var range = sel.getRangeAt(0);
651 var code = document.createElement('code');
652 try { range.surroundContents(code); } catch (e) {
653 code.appendChild(range.extractContents());
654 range.insertNode(code);
655 }
656 }
657 }
658 syncHidden();
659 });
660 });
661
662 modeBtn.addEventListener('click', function (ev) {
663 ev.preventDefault();
664 if (mode === 'rendered') {
665 source.value = rendered.innerHTML;
666 rendered.style.display = 'none';
667 source.style.display = 'block';
668 mode = 'source';
669 modeBtn.innerHTML = 'Rendered &#8644;';
670 source.focus();
671 } else {
672 rendered.innerHTML = source.value;
673 source.style.display = 'none';
674 rendered.style.display = 'block';
675 mode = 'rendered';
676 modeBtn.innerHTML = 'Source &#8644;';
677 rendered.focus();
678 }
679 syncHidden();
680 });
681
682 rendered.addEventListener('input', syncHidden);
683 source.addEventListener('input', syncHidden);
684
685 var form = root.closest('form');
686 if (form) form.addEventListener('submit', syncHidden);
687 })();
688 </script>
689 <div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px;margin-bottom:14px">
690 <div>
691 <label style="display:block;font-size:12px;text-transform:uppercase;letter-spacing:.05em;color:#cbd5e1;margin-bottom:6px">Audience</label>
692 <select name="audience_kind" id="ann-aud-kind" style="width:100%;background:#0e131c;color:#fff;border:1px solid rgba(255,255,255,.10);border-radius:8px;padding:10px;font-size:14px">$aud_opts</select>
693 </div>
694 <div>
695 <label style="display:block;font-size:12px;text-transform:uppercase;letter-spacing:.05em;color:#cbd5e1;margin-bottom:6px">Scheduled send (optional)</label>
696 <input type="datetime-local" name="scheduled_send_at" value="$sch_h"
697 style="width:100%;background:#0e131c;color:#fff;border:1px solid rgba(255,255,255,.10);border-radius:8px;padding:10px;font-size:14px">
698 </div>
699 <div style="display:flex;align-items:end;padding-bottom:10px">
700 <label style="display:flex;align-items:center;gap:8px;font-size:13px;color:#cbd5e1">
701 <input type="checkbox" name="send_email" value="1" $send_ck> Send email blast on publish
702 </label>
703 </div>
704 </div>
705 <div id="ann-aud-prog-row" style="display:$prog_picker_display;margin-bottom:14px">
706 <label style="display:block;font-size:12px;text-transform:uppercase;letter-spacing:.05em;color:#cbd5e1;margin-bottom:6px">Program</label>
707 <select name="audience_program_id" id="ann-aud-program" style="width:100%;background:#0e131c;color:#fff;border:1px solid rgba(255,255,255,.10);border-radius:8px;padding:10px;font-size:14px">$prog_opts</select>
708 <div style="margin-top:4px;font-size:11px;color:#94a3b8">Only approved affiliates in this program will receive the announcement.</div>
709 </div>
710 <script>
711 (function () {
712 var kind = document.getElementById('ann-aud-kind');
713 var row = document.getElementById('ann-aud-prog-row');
714 var prog = document.getElementById('ann-aud-program');
715 if (!kind || !row || !prog) return;
716 function sync() {
717 if (kind.value === 'program_affiliates') {
718 row.style.display = 'block';
719 } else {
720 row.style.display = 'none';
721 prog.value = ''; // clear so a stale id doesn't ride along on save
722 }
723 }
724 kind.addEventListener('change', sync);
725 // Don't call sync() on load -- the server already rendered the
726 // correct display state AND the right selected option for an
727 // edit; calling sync() would wipe the selection.
728 })();
729 </script>
730 <div style="display:flex;gap:10px;flex-wrap:wrap">
731 <button type="submit" name="act" value="save" class="btn"
732 style="background:#2a313d;color:#cdd;border:none;padding:10px 20px;border-radius:8px;font-weight:600;cursor:pointer;font-size:14px">Save draft</button>
733 <button type="submit" name="act" value="schedule" class="btn"
734 style="background:#fbbf24;color:#1a1a1a;border:none;padding:10px 20px;border-radius:8px;font-weight:600;cursor:pointer;font-size:14px">Schedule</button>
735 <button type="submit" name="act" value="publish_now" class="btn js-confirm-publish"
736 style="background:#5aa9ff;color:#fff;border:none;padding:10px 20px;border-radius:8px;font-weight:600;cursor:pointer;font-size:14px">Publish now</button>
737 </div>
738 </form>
739~ . ($e_id ? qq~
740 <form method="POST" action="/admin_email_announcements.cgi" style="margin-top:18px;padding:14px 16px;border:1px solid rgba(91,33,182,.3);background:rgba(91,33,182,.05);border-radius:10px">
741 <input type="hidden" name="act" value="send_test">
742 <input type="hidden" name="announcement_id" value="$e_id">
743 <label style="display:block;font-size:12px;text-transform:uppercase;letter-spacing:.05em;color:#cbd5e1;margin-bottom:6px">Send test (drained by next worker run)</label>
744 <div style="display:flex;gap:8px">
745 <input type="email" name="test_to" value="$test_email" required
746 style="flex:1;background:#0e131c;color:#fff;border:1px solid rgba(255,255,255,.10);border-radius:8px;padding:10px;font-size:14px">
747 <button type="submit" class="btn" style="background:#2a313d;color:#cdd;border:none;padding:10px 20px;border-radius:8px;font-weight:600;cursor:pointer;font-size:14px">Send test &rarr;</button>
748 </div>
749 </form>
750~ : '') . qq~
751 </div>
752
753 <h2 style="font-size:18px;color:#fff;margin:0 0 14px">All announcements</h2>
754 <div style="display:flex;gap:8px;margin-bottom:14px;flex-wrap:wrap">
755$filter_chips_html
756 </div>
757 <div style="border:1px solid rgba(255,255,255,.08);border-radius:12px;overflow:hidden;background:rgba(20,20,30,.4)">
758 <table style="width:100%;border-collapse:collapse">
759 <thead><tr>
760 <th style="padding:10px 12px;text-align:left;font-size:11px;color:#94a3b8;text-transform:uppercase;letter-spacing:.05em">Title</th>
761 <th style="padding:10px 12px;text-align:left;font-size:11px;color:#94a3b8;text-transform:uppercase;letter-spacing:.05em">Status</th>
762 <th style="padding:10px 12px;text-align:left;font-size:11px;color:#94a3b8;text-transform:uppercase;letter-spacing:.05em">Scheduled</th>
763 <th style="padding:10px 12px;text-align:left;font-size:11px;color:#94a3b8;text-transform:uppercase;letter-spacing:.05em">Published</th>
764 <th style="padding:10px 12px;text-align:left;font-size:11px;color:#94a3b8;text-transform:uppercase;letter-spacing:.05em">Email</th>
765 <th style="padding:10px 12px;text-align:left;font-size:11px;color:#94a3b8;text-transform:uppercase;letter-spacing:.05em">Reads</th>
766 <th style="padding:10px 12px;text-align:left;font-size:11px;color:#94a3b8;text-transform:uppercase;letter-spacing:.05em">Actions</th>
767 </tr></thead>
768 <tbody>$rows_html</tbody>
769 </table>
770 </div>
771</div>
772
773<!-- Styled confirmation modal (replaces browser confirm) -->
774<div id="cmodal-back" style="display:none;position:fixed;inset:0;z-index:9000;background:rgba(8,10,16,.75);backdrop-filter:blur(4px);align-items:center;justify-content:center">
775 <div style="background:#1a1f2a;color:#e2e8f0;max-width:480px;width:90%;border:1px solid rgba(239,68,68,.40);border-radius:12px;overflow:hidden;box-shadow:0 20px 60px rgba(0,0,0,.5)">
776 <div style="padding:18px 22px;border-bottom:1px solid rgba(255,255,255,.08)">
777 <h3 id="cmodal-title" style="margin:0;font-size:18px;color:#fff">Confirm</h3>
778 </div>
779 <div id="cmodal-body" style="padding:18px 22px;color:#cbd5e1;font-size:14px;line-height:1.5"></div>
780 <div style="padding:14px 22px;border-top:1px solid rgba(255,255,255,.08);display:flex;justify-content:flex-end;gap:10px;background:rgba(0,0,0,.2)">
781 <button id="cmodal-cancel" type="button" style="background:transparent;border:1px solid rgba(255,255,255,.15);color:#cbd5e1;padding:8px 18px;border-radius:6px;cursor:pointer;font-size:13px">Cancel</button>
782 <button id="cmodal-ok" type="button" style="background:#dc2626;color:#fff;border:none;padding:8px 18px;border-radius:6px;cursor:pointer;font-size:13px;font-weight:600">Confirm</button>
783 </div>
784 </div>
785</div>
786<script>
787(function () {
788 var back = document.getElementById('cmodal-back');
789 var titleEl = document.getElementById('cmodal-title');
790 var bodyEl = document.getElementById('cmodal-body');
791 var okBtn = document.getElementById('cmodal-ok');
792 var cancelBtn=document.getElementById('cmodal-cancel');
793 var pending = null;
794 function open(opts) {
795 titleEl.textContent = opts.title || 'Confirm';
796 bodyEl.textContent = opts.body || '';
797 okBtn.textContent = opts.okLabel || 'Confirm';
798 okBtn.style.background = opts.okColor || '#dc2626';
799 back.style.display = 'flex';
800 pending = opts.onConfirm;
801 }
802 function close() { back.style.display = 'none'; pending = null; }
803 okBtn.addEventListener('click', function () {
804 var p = pending; close(); if (p) p();
805 });
806 cancelBtn.addEventListener('click', close);
807 back.addEventListener('click', function (ev) { if (ev.target === back) close(); });
808 document.addEventListener('keydown', function (ev) {
809 if (ev.key === 'Escape' && back.style.display === 'flex') close();
810 });
811
812 // Delete buttons
813 document.querySelectorAll('.js-del-ann').forEach(function (btn) {
814 btn.addEventListener('click', function () {
815 var id = btn.getAttribute('data-id');
816 var t = btn.getAttribute('data-title');
817 open({
818 title: 'Delete announcement?',
819 body: 'Permanently delete "' + t + '" (#' + id + ') and all read receipts. This cannot be undone.',
820 okLabel: 'Delete',
821 onConfirm: function () {
822 var form = document.createElement('form');
823 form.method = 'POST'; form.action = '/admin_email_announcements.cgi';
824 form.innerHTML = '<input name="act" value="delete"><input name="announcement_id" value="' + id + '">';
825 document.body.appendChild(form); form.submit();
826 },
827 });
828 });
829 });
830
831 // Confirm Publish-now from the LIST row (vs the composer-form button).
832 document.querySelectorAll('.js-pub-now-form').forEach(function (frm) {
833 frm.addEventListener('submit', function (ev) {
834 if (frm.__confirmed) return; // re-submitted after confirm
835 ev.preventDefault();
836 var title = frm.getAttribute('data-title') || 'this announcement';
837 open({
838 title: 'Publish now?',
839 body: 'Publish "' + title + '" to the selected audience right now. This fires push notifications and queues the email blast if enabled.',
840 okLabel: 'Publish now',
841 okColor: '#5aa9ff',
842 onConfirm: function () {
843 frm.__confirmed = true;
844 frm.submit();
845 },
846 });
847 });
848 });
849
850 // Confirm Publish-now (it fires push + email blast)
851 var pubBtn = document.querySelector('.js-confirm-publish');
852 if (pubBtn) {
853 pubBtn.addEventListener('click', function (ev) {
854 var f = pubBtn.form;
855 var send = f.querySelector('input[name=send_email]');
856 var msg = 'Publish this announcement to the selected audience right now';
857 if (send && send.checked) msg += ', AND queue an email blast to every recipient';
858 msg += '. Continue?';
859 ev.preventDefault();
860 open({
861 title: 'Publish announcement?',
862 body: msg,
863 okLabel: 'Publish now',
864 okColor: '#5aa9ff',
865 onConfirm: function () {
866 // Mark the act value before re-submit
867 var actInput = document.createElement('input');
868 actInput.type = 'hidden'; actInput.name = 'act'; actInput.value = 'publish_now';
869 f.appendChild(actInput);
870 f.submit();
871 },
872 });
873 });
874 }
875})();
876</script>
877~;
878
879$wrap->render({ userinfo => $u, page_key => 'email_setup', title => 'Announcements', body => $body });