Diff -- /var/www/vhosts/3dshawn.com/affiliate.3dshawn.com/admin_emails.cgi
Diff
/var/www/vhosts/3dshawn.com/affiliate.3dshawn.com/admin_emails.cgi
added on local at 2026-07-11 18:38:32
Added
+565
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to cbb15010a579
to cbb15010a579
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_emails.cgi | |
| 4 | # | |
| 5 | # List + edit + preview + send-test email templates from the | |
| 6 | # email_templates table. Super-admin only. | |
| 7 | # | |
| 8 | # GET list templates (or ?edit=<slug> to edit one) | |
| 9 | # POST act=save update subject/body | |
| 10 | # POST act=send_test send a rendered copy to the admin doing the test | |
| 11 | #====================================================================== | |
| 12 | use strict; | |
| 13 | use warnings; | |
| 14 | ||
| 15 | use lib '/var/www/vhosts/3dshawn.com/affiliate.3dshawn.com'; | |
| 16 | use CGI; | |
| 17 | use MODS::DBConnect; | |
| 18 | use MODS::AffSoft::Wrapper; | |
| 19 | use MODS::AffSoft::Config; | |
| 20 | use MODS::Login; | |
| 21 | use MODS::AffSoft::Mailer; | |
| 22 | ||
| 23 | my $q = CGI->new; | |
| 24 | my $form = $q->Vars; | |
| 25 | my $wrap = MODS::AffSoft::Wrapper->new; | |
| 26 | my $db = MODS::DBConnect->new; | |
| 27 | my $cfg = MODS::AffSoft::Config->new; | |
| 28 | my $DB = $cfg->settings('database_name'); | |
| 29 | ||
| 30 | my $auth = MODS::Login->new; | |
| 31 | my $u = $auth->login_verify; | |
| 32 | unless ($u) { print "Status: 302 Found\nLocation: /login.cgi\n\n"; exit; } | |
| 33 | require MODS::AffSoft::Permissions; | |
| 34 | MODS::AffSoft::Permissions->new->require_super_admin($u); | |
| 35 | my $uid = $u->{user_id} + 0; | |
| 36 | ||
| 37 | my $dbh = $db->db_connect; | |
| 38 | my $act = $form->{act} || ''; | |
| 39 | my $flash = ''; | |
| 40 | ||
| 41 | # ---- POST handlers -------------------------------------------------- | |
| 42 | if (($ENV{REQUEST_METHOD} || '') eq 'POST') { | |
| 43 | if ($act eq 'save') { | |
| 44 | my $tid = ($form->{template_id} || 0) + 0; | |
| 45 | my $name = substr($form->{name} || '', 0, 160); $name =~ s/'/''/g; | |
| 46 | my $subj = substr($form->{subject} || '', 0, 255); $subj =~ s/'/''/g; | |
| 47 | my $body = substr($form->{body_html} || '', 0, 65000); $body =~ s/'/''/g; | |
| 48 | my $cat = $form->{category} || 'lifecycle'; | |
| 49 | $cat = 'lifecycle' unless $cat =~ /^(transactional|lifecycle|product|marketing)$/; | |
| 50 | my $active = $form->{is_active} ? 1 : 0; | |
| 51 | $db->db_readwrite($dbh, qq~ | |
| 52 | UPDATE ${DB}.email_templates | |
| 53 | SET name='$name', subject='$subj', body_html='$body', | |
| 54 | category='$cat', is_active='$active', | |
| 55 | updated_by_user_id='$uid' | |
| 56 | WHERE id='$tid' | |
| 57 | ~, $0, __LINE__); | |
| 58 | $flash = 'Saved.'; | |
| 59 | } | |
| 60 | elsif ($act eq 'send_test') { | |
| 61 | my $slug = $form->{slug} || ''; | |
| 62 | $slug =~ s/[^a-z0-9_-]//g; | |
| 63 | my $to = $form->{test_to} || $u->{email} || ''; | |
| 64 | my $mailer = MODS::AffSoft::Mailer->new; | |
| 65 | my %sample = ( | |
| 66 | display_name => $u->{display_name} || 'Test User', | |
| 67 | first_name => (split /\s+/, ($u->{display_name} || 'Test'))[0], | |
| 68 | company_name => $u->{company_name} || 'Test Workspace', | |
| 69 | trial_ends_at => '2026-07-04 09:00:00', | |
| 70 | days_left => 7, | |
| 71 | inviter_name => 'You', | |
| 72 | inviter_email => $u->{email} || '', | |
| 73 | accept_url => 'https://affiliate.3dshawn.com/accept_invite.cgi?token=SAMPLE', | |
| 74 | reset_url => 'https://affiliate.3dshawn.com/reset_password.cgi?t=SAMPLE', | |
| 75 | ); | |
| 76 | my $ok = $mailer->send_template( | |
| 77 | slug => $slug, | |
| 78 | user_id => $uid, | |
| 79 | to => $to, | |
| 80 | vars => \%sample, | |
| 81 | ); | |
| 82 | $flash = $ok ? "Test sent to $to." : "Test failed -- check _mail.log and email_sends_log."; | |
| 83 | } | |
| 84 | } | |
| 85 | ||
| 86 | # ---- GET (list or edit) --------------------------------------------- | |
| 87 | my $edit_slug = $form->{edit} || ''; | |
| 88 | $edit_slug =~ s/[^a-z0-9_-]//g; | |
| 89 | ||
| 90 | my @templates = $db->db_readwrite_multiple($dbh, qq~ | |
| 91 | SELECT t.id, t.slug, t.category, t.name, t.subject, t.body_html, | |
| 92 | t.is_system, t.is_active, t.updated_at, | |
| 93 | (SELECT COUNT(*) FROM ${DB}.email_schedules WHERE template_id=t.id AND is_active=1) AS sched_count | |
| 94 | FROM ${DB}.email_templates t | |
| 95 | ORDER BY t.category, t.slug | |
| 96 | ~, $0, __LINE__); | |
| 97 | ||
| 98 | # Last 20 sends for the activity panel | |
| 99 | my @recent_sends = $db->db_readwrite_multiple($dbh, qq~ | |
| 100 | SELECT id, user_id, template_slug, to_email, subject, status, failure_reason, created_at, | |
| 101 | UNIX_TIMESTAMP(created_at) AS created_at_epoch | |
| 102 | FROM ${DB}.email_sends_log ORDER BY id DESC LIMIT 20 | |
| 103 | ~, $0, __LINE__); | |
| 104 | ||
| 105 | ||
| 106 | $db->db_disconnect($dbh); | |
| 107 | ||
| 108 | print "Content-Type: text/html; charset=utf-8\r\nCache-Control: no-store\r\n\r\n"; | |
| 109 | ||
| 110 | my $body; | |
| 111 | my $flash_html = $flash ? qq~<div style="padding:10px 14px;background:rgba(34,197,94,.12);border:1px solid rgba(34,197,94,.3);border-radius:8px;color:#86efac;font-size:13px;margin-bottom:18px">$flash</div>~ : ''; | |
| 112 | ||
| 113 | if ($edit_slug) { | |
| 114 | my ($t) = grep { $_->{slug} eq $edit_slug } @templates; | |
| 115 | if ($t) { | |
| 116 | my $name = _h($t->{name}); | |
| 117 | my $subj = _h($t->{subject}); | |
| 118 | my $body_h = _h($t->{body_html}); | |
| 119 | my $cat = $t->{category}; | |
| 120 | my $checked = $t->{is_active} ? 'checked' : ''; | |
| 121 | my $is_sys = $t->{is_system} ? '<span style="font-size:11px;padding:2px 8px;border-radius:4px;background:rgba(139,92,246,.18);color:#c4b5fd;margin-left:8px">SYSTEM</span>' : ''; | |
| 122 | my @opts; | |
| 123 | foreach my $k (qw(transactional lifecycle product marketing)) { | |
| 124 | my $sel = ($k eq $cat) ? ' selected' : ''; | |
| 125 | push @opts, qq~<option value="$k"$sel>$k</option>~; | |
| 126 | } | |
| 127 | my $opt_html = join('', @opts); | |
| 128 | my $tabs = _email_setup_tabs('templates'); | |
| 129 | $body = qq~ | |
| 130 | <div class="page-pad"> | |
| 131 | <h1 style="margin:0 0 6px;font-size:28px;color:#fff">Email Setup</h1> | |
| 132 | <p style="color:#94a3b8;margin:0 0 18px;font-size:14px">Templates, schedules, and spam-protection controls.</p> | |
| 133 | $tabs | |
| 134 | <a href="/admin_emails.cgi" style="color:#94a3b8;font-size:13px;text-decoration:none">← All templates</a> | |
| 135 | <h2 style="margin:8px 0 6px;font-size:22px;color:#fff">Edit: $name $is_sys</h2> | |
| 136 | <p style="color:#94a3b8;margin:0 0 24px;font-size:14px"><code style="color:#c4b5fd">$t->{slug}</code> · category <strong>$cat</strong></p> | |
| 137 | $flash_html | |
| 138 | <form method="POST" action="/admin_emails.cgi" style="display:grid;grid-template-columns:1fr 280px;gap:24px"> | |
| 139 | <div> | |
| 140 | <input type="hidden" name="act" value="save"> | |
| 141 | <input type="hidden" name="template_id" value="$t->{id}"> | |
| 142 | <label style="display:block;font-size:12px;text-transform:uppercase;letter-spacing:.05em;color:#cbd5e1;margin-bottom:6px">Name</label> | |
| 143 | <input class="input" type="text" name="name" value="$name" maxlength="160" style="width:100%;margin-bottom:14px"> | |
| 144 | <label style="display:block;font-size:12px;text-transform:uppercase;letter-spacing:.05em;color:#cbd5e1;margin-bottom:6px">Category</label> | |
| 145 | <select class="select" name="category" style="width:100%;margin-bottom:14px">$opt_html</select> | |
| 146 | <label style="display:block;font-size:12px;text-transform:uppercase;letter-spacing:.05em;color:#cbd5e1;margin-bottom:6px">Subject</label> | |
| 147 | <input class="input" type="text" name="subject" value="$subj" maxlength="255" style="width:100%;margin-bottom:14px"> | |
| 148 | <label style="display:block;font-size:12px;text-transform:uppercase;letter-spacing:.05em;color:#cbd5e1;margin-bottom:6px">Body</label> | |
| 149 | <div class="wyse" id="wyse" style="margin-bottom:14px"> | |
| 150 | <div class="wyse-toolbar"> | |
| 151 | <button type="button" class="wbtn" data-cmd="bold" title="Bold (Ctrl+B)"><b>B</b></button> | |
| 152 | <button type="button" class="wbtn" data-cmd="italic" title="Italic (Ctrl+I)"><i>I</i></button> | |
| 153 | <button type="button" class="wbtn" data-cmd="underline" title="Underline"><u>U</u></button> | |
| 154 | <button type="button" class="wbtn" data-cmd="strikeThrough" title="Strikethrough"><s>S</s></button> | |
| 155 | <span class="wsep"></span> | |
| 156 | <button type="button" class="wbtn wbtn-text" data-block="h1" title="Heading 1">H1</button> | |
| 157 | <button type="button" class="wbtn wbtn-text" data-block="h2" title="Heading 2">H2</button> | |
| 158 | <button type="button" class="wbtn wbtn-text" data-block="h3" title="Heading 3">H3</button> | |
| 159 | <button type="button" class="wbtn wbtn-text" data-block="p" title="Paragraph">P</button> | |
| 160 | <span class="wsep"></span> | |
| 161 | <button type="button" class="wbtn" data-cmd="insertUnorderedList" title="Bulleted list">•</button> | |
| 162 | <button type="button" class="wbtn" data-cmd="insertOrderedList" title="Numbered list">1.</button> | |
| 163 | <button type="button" class="wbtn wbtn-text" data-block="blockquote" title="Block quote">“</button> | |
| 164 | <button type="button" class="wbtn wbtn-text" data-action="code" title="Inline code"></></button> | |
| 165 | <span class="wsep"></span> | |
| 166 | <button type="button" class="wbtn wbtn-text" data-action="link" title="Insert link">Link</button> | |
| 167 | <button type="button" class="wbtn" data-cmd="unlink" title="Remove link">🔗</button> | |
| 168 | <button type="button" class="wbtn wbtn-text" data-cmd="insertHorizontalRule" title="Insert horizontal rule">—</button> | |
| 169 | <span class="wsep"></span> | |
| 170 | <select class="wsel" id="wyse-var" title="Insert a template variable at the cursor"> | |
| 171 | <option value="">+ Variable</option> | |
| 172 | <option value="{{display_name}}">display_name</option> | |
| 173 | <option value="{{first_name}}">first_name</option> | |
| 174 | <option value="{{company_name}}">company_name</option> | |
| 175 | <option value="{{trial_ends_at}}">trial_ends_at</option> | |
| 176 | <option value="{{days_left}}">days_left</option> | |
| 177 | <option value="{{billing_url}}">billing_url</option> | |
| 178 | <option value="{{dashboard_url}}">dashboard_url</option> | |
| 179 | <option value="{{unsubscribe_url}}">unsubscribe_url</option> | |
| 180 | <option value="{{inviter_name}}">inviter_name</option> | |
| 181 | <option value="{{inviter_email}}">inviter_email</option> | |
| 182 | <option value="{{accept_url}}">accept_url</option> | |
| 183 | <option value="{{reset_url}}">reset_url</option> | |
| 184 | </select> | |
| 185 | <span class="wsep" style="margin-left:auto"></span> | |
| 186 | <button type="button" class="wbtn wbtn-text wbtn-preview" id="wyse-preview" title="Preview the email with sample values substituted">Preview ▶</button> | |
| 187 | <button type="button" class="wbtn wbtn-text wbtn-mode" id="wyse-mode" title="Toggle between rendered HTML and raw source">Source ⇄</button> | |
| 188 | </div> | |
| 189 | <div class="wyse-rendered" id="wyse-rendered" contenteditable="true" spellcheck="true"></div> | |
| 190 | <textarea class="wyse-source" id="wyse-source" style="display:none">$body_h</textarea> | |
| 191 | </div> | |
| 192 | <input type="hidden" name="body_html" id="wyse-hidden"> | |
| 193 | <div class="wprev-backdrop" id="wprev-backdrop" role="dialog" aria-modal="true"> | |
| 194 | <div class="wprev-modal"> | |
| 195 | <div class="wprev-head"> | |
| 196 | <div class="wprev-title">Preview · sample values substituted</div> | |
| 197 | <button type="button" class="wprev-close" id="wprev-close">Close</button> | |
| 198 | </div> | |
| 199 | <div class="wprev-meta"> | |
| 200 | <div class="wprev-from">From: AffSoft <noreply\@affiliate.3dshawn.com> · To: shawn\@example.com</div> | |
| 201 | <div class="wprev-subject" id="wprev-subject"></div> | |
| 202 | </div> | |
| 203 | <div class="wprev-body" id="wprev-body"></div> | |
| 204 | </div> | |
| 205 | </div> | |
| 206 | <style> | |
| 207 | .wyse { border:1px solid rgba(255,255,255,.10); border-radius:10px; overflow:hidden; background:#0f1420; } | |
| 208 | .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; } | |
| 209 | .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; } | |
| 210 | .wyse .wbtn:hover { background:rgba(255,255,255,.06); color:#fff; } | |
| 211 | .wyse .wbtn.is-active { background:rgba(90,169,255,.20); color:#bfdbff; border-color:rgba(90,169,255,.35); } | |
| 212 | .wyse .wbtn-text { font-size:12px; font-weight:600; } | |
| 213 | .wyse .wsep { width:1px; height:18px; background:rgba(255,255,255,.10); margin:0 6px; } | |
| 214 | .wyse .wsel { background:#0f1420; color:#cbd5e1; border:1px solid rgba(255,255,255,.12); border-radius:6px; padding:5px 8px; font-size:12px; cursor:pointer; height:30px; } | |
| 215 | .wyse .wbtn-mode { background:rgba(139,92,246,.15); color:#c4b5fd; border-color:rgba(139,92,246,.30); } | |
| 216 | .wyse .wbtn-mode:hover { background:rgba(139,92,246,.25); } | |
| 217 | .wyse .wbtn-preview { background:rgba(34,197,94,.14); color:#86efac; border-color:rgba(34,197,94,.30); } | |
| 218 | .wyse .wbtn-preview:hover { background:rgba(34,197,94,.24); } | |
| 219 | .wprev-backdrop { position:fixed; inset:0; background:rgba(0,0,0,.65); z-index:9000; display:none; align-items:flex-start; justify-content:center; padding:40px 16px; overflow:auto; } | |
| 220 | .wprev-backdrop.is-open { display:flex; } | |
| 221 | .wprev-modal { background:#0b0f1a; color:#e2e8f0; max-width:720px; width:100%; border:1px solid rgba(255,255,255,.10); border-radius:14px; overflow:hidden; box-shadow:0 20px 60px rgba(0,0,0,.5); } | |
| 222 | .wprev-head { display:flex; align-items:center; padding:14px 20px; border-bottom:1px solid rgba(255,255,255,.10); background:#171b2a; gap:14px; } | |
| 223 | .wprev-head .wprev-title { font-size:12px; text-transform:uppercase; letter-spacing:.06em; color:#94a3b8; font-weight:600; } | |
| 224 | .wprev-head .wprev-close { margin-left:auto; background:transparent; border:1px solid rgba(255,255,255,.12); color:#cbd5e1; padding:6px 14px; border-radius:6px; cursor:pointer; font-size:13px; } | |
| 225 | .wprev-head .wprev-close:hover { background:rgba(255,255,255,.06); } | |
| 226 | .wprev-meta { padding:12px 20px; font-size:12px; color:#94a3b8; border-bottom:1px solid rgba(255,255,255,.05); background:rgba(20,20,30,.3); } | |
| 227 | .wprev-meta .wprev-from { color:#cbd5e1; font-weight:500; } | |
| 228 | .wprev-meta .wprev-subject { color:#f1f5f9; font-weight:700; font-size:15px; margin-top:6px; } | |
| 229 | .wprev-body { padding:24px 28px; line-height:1.65; font-size:14px; max-height:60vh; overflow:auto; } | |
| 230 | .wprev-body p { margin:.6em 0; } | |
| 231 | .wprev-body h1 { font-size:22px; margin:.4em 0 .3em; color:#f1f5f9; } | |
| 232 | .wprev-body h2 { font-size:18px; margin:.5em 0 .3em; color:#f1f5f9; } | |
| 233 | .wprev-body h3 { font-size:16px; margin:.5em 0 .3em; color:#f1f5f9; } | |
| 234 | .wprev-body ul, .wprev-body ol { padding-left:1.6em; margin:.5em 0; } | |
| 235 | .wprev-body a { color:#5aa9ff; text-decoration:underline; } | |
| 236 | .wprev-body code { background:rgba(255,255,255,.06); padding:1px 6px; border-radius:4px; font-family:ui-monospace,Consolas,Menlo,monospace; font-size:12.5px; } | |
| 237 | .wprev-body blockquote { border-left:3px solid rgba(255,255,255,.20); margin:.8em 0; padding:.2em 1em; color:#cbd5e1; } | |
| 238 | .wprev-body hr { border:0; border-top:1px solid rgba(255,255,255,.15); margin:1em 0; } | |
| 239 | .wyse-rendered { min-height:380px; padding:16px 20px; color:#e2e8f0; font-size:14px; line-height:1.6; outline:none; background:#0b0f1a; } | |
| 240 | .wyse-rendered:focus { background:#0b0f1a; } | |
| 241 | .wyse-rendered h1 { font-size:22px; margin:.4em 0 .3em; color:#f1f5f9; } | |
| 242 | .wyse-rendered h2 { font-size:18px; margin:.5em 0 .3em; color:#f1f5f9; } | |
| 243 | .wyse-rendered h3 { font-size:16px; margin:.5em 0 .3em; color:#f1f5f9; } | |
| 244 | .wyse-rendered p { margin:.5em 0; } | |
| 245 | .wyse-rendered ul, .wyse-rendered ol { padding-left:1.6em; margin:.5em 0; } | |
| 246 | .wyse-rendered a { color:#5aa9ff; text-decoration:underline; } | |
| 247 | .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; } | |
| 248 | .wyse-rendered blockquote { border-left:3px solid rgba(255,255,255,.20); margin:.8em 0; padding:.2em 1em; color:#cbd5e1; } | |
| 249 | .wyse-rendered hr { border:0; border-top:1px solid rgba(255,255,255,.15); margin:1em 0; } | |
| 250 | .wyse-source { width:100%; min-height:380px; 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; } | |
| 251 | </style> | |
| 252 | <script> | |
| 253 | (function () { | |
| 254 | var root = document.getElementById('wyse'); | |
| 255 | var rendered= document.getElementById('wyse-rendered'); | |
| 256 | var source = document.getElementById('wyse-source'); | |
| 257 | var hidden = document.getElementById('wyse-hidden'); | |
| 258 | var modeBtn = document.getElementById('wyse-mode'); | |
| 259 | var varSel = document.getElementById('wyse-var'); | |
| 260 | if (!root || !rendered || !source) return; | |
| 261 | ||
| 262 | var mode = 'rendered'; | |
| 263 | ||
| 264 | // Hydrate the rendered pane from the source HTML on load. | |
| 265 | rendered.innerHTML = source.value; | |
| 266 | hidden.value = source.value; | |
| 267 | ||
| 268 | function syncHidden() { | |
| 269 | if (mode === 'rendered') { | |
| 270 | source.value = rendered.innerHTML; | |
| 271 | } else { | |
| 272 | rendered.innerHTML = source.value; | |
| 273 | } | |
| 274 | hidden.value = source.value; | |
| 275 | } | |
| 276 | ||
| 277 | // Toolbar formatting buttons via execCommand (legacy but ubiquitous). | |
| 278 | root.querySelectorAll('.wbtn[data-cmd]').forEach(function (b) { | |
| 279 | b.addEventListener('click', function (ev) { | |
| 280 | ev.preventDefault(); | |
| 281 | if (mode !== 'rendered') return; | |
| 282 | rendered.focus(); | |
| 283 | document.execCommand(b.getAttribute('data-cmd'), false, null); | |
| 284 | syncHidden(); | |
| 285 | }); | |
| 286 | }); | |
| 287 | ||
| 288 | // Block-level (H1/H2/H3/P/blockquote) | |
| 289 | root.querySelectorAll('.wbtn[data-block]').forEach(function (b) { | |
| 290 | b.addEventListener('click', function (ev) { | |
| 291 | ev.preventDefault(); | |
| 292 | if (mode !== 'rendered') return; | |
| 293 | rendered.focus(); | |
| 294 | document.execCommand('formatBlock', false, b.getAttribute('data-block')); | |
| 295 | syncHidden(); | |
| 296 | }); | |
| 297 | }); | |
| 298 | ||
| 299 | // Action buttons (custom) | |
| 300 | root.querySelectorAll('.wbtn[data-action]').forEach(function (b) { | |
| 301 | b.addEventListener('click', function (ev) { | |
| 302 | ev.preventDefault(); | |
| 303 | if (mode !== 'rendered') return; | |
| 304 | rendered.focus(); | |
| 305 | var action = b.getAttribute('data-action'); | |
| 306 | if (action === 'link') { | |
| 307 | var url = prompt('URL?', 'https://'); | |
| 308 | if (url) document.execCommand('createLink', false, url); | |
| 309 | } else if (action === 'code') { | |
| 310 | var sel = window.getSelection(); | |
| 311 | if (sel && sel.rangeCount && !sel.isCollapsed) { | |
| 312 | var range = sel.getRangeAt(0); | |
| 313 | var code = document.createElement('code'); | |
| 314 | try { range.surroundContents(code); } catch (e) { | |
| 315 | // fallback for cross-element selections | |
| 316 | code.appendChild(range.extractContents()); | |
| 317 | range.insertNode(code); | |
| 318 | } | |
| 319 | } | |
| 320 | } | |
| 321 | syncHidden(); | |
| 322 | }); | |
| 323 | }); | |
| 324 | ||
| 325 | // Insert variable from dropdown | |
| 326 | if (varSel) { | |
| 327 | varSel.addEventListener('change', function () { | |
| 328 | var v = varSel.value; | |
| 329 | if (!v) return; | |
| 330 | if (mode === 'rendered') { | |
| 331 | rendered.focus(); | |
| 332 | document.execCommand('insertText', false, v); | |
| 333 | } else { | |
| 334 | var ta = source; | |
| 335 | var start = ta.selectionStart, end = ta.selectionEnd; | |
| 336 | ta.value = ta.value.slice(0, start) + v + ta.value.slice(end); | |
| 337 | ta.selectionStart = ta.selectionEnd = start + v.length; | |
| 338 | ta.focus(); | |
| 339 | } | |
| 340 | varSel.value = ''; | |
| 341 | syncHidden(); | |
| 342 | }); | |
| 343 | } | |
| 344 | ||
| 345 | // Toggle Source / Rendered | |
| 346 | modeBtn.addEventListener('click', function (ev) { | |
| 347 | ev.preventDefault(); | |
| 348 | if (mode === 'rendered') { | |
| 349 | // -> source | |
| 350 | source.value = rendered.innerHTML; | |
| 351 | rendered.style.display = 'none'; | |
| 352 | source.style.display = 'block'; | |
| 353 | mode = 'source'; | |
| 354 | modeBtn.innerHTML = 'Rendered ⇄'; | |
| 355 | source.focus(); | |
| 356 | } else { | |
| 357 | // -> rendered | |
| 358 | rendered.innerHTML = source.value; | |
| 359 | source.style.display = 'none'; | |
| 360 | rendered.style.display = 'block'; | |
| 361 | mode = 'rendered'; | |
| 362 | modeBtn.innerHTML = 'Source ⇄'; | |
| 363 | rendered.focus(); | |
| 364 | } | |
| 365 | syncHidden(); | |
| 366 | }); | |
| 367 | ||
| 368 | // Keep hidden field current as the user types | |
| 369 | rendered.addEventListener('input', syncHidden); | |
| 370 | source.addEventListener('input', syncHidden); | |
| 371 | ||
| 372 | // On form submit, force-sync once more so the latest content posts | |
| 373 | var form = root.closest('form'); | |
| 374 | if (form) form.addEventListener('submit', syncHidden); | |
| 375 | ||
| 376 | // ----- Preview overlay --------------------------------------- | |
| 377 | // Sample values shown in the right-side panel + used when the | |
| 378 | // user clicks Preview. Single source of truth so the panel and | |
| 379 | // the rendered preview always agree. | |
| 380 | var SAMPLES = { | |
| 381 | display_name : 'Shawn (Platform Owner)', | |
| 382 | first_name : 'Shawn', | |
| 383 | company_name : "Shawn's Workspace", | |
| 384 | trial_ends_at : '2026-07-04 09:00:00', | |
| 385 | days_left : '7', | |
| 386 | billing_url : 'https://affiliate.3dshawn.com/billing.cgi', | |
| 387 | dashboard_url : 'https://affiliate.3dshawn.com/dashboard.cgi', | |
| 388 | unsubscribe_url: 'https://affiliate.3dshawn.com/unsubscribe.cgi?t=SAMPLE', | |
| 389 | inviter_name : 'Shawn', | |
| 390 | inviter_email : 'shawn\@example.com', | |
| 391 | accept_url : 'https://affiliate.3dshawn.com/accept_invite.cgi?token=SAMPLE', | |
| 392 | reset_url : 'https://affiliate.3dshawn.com/reset_password.cgi?t=SAMPLE' | |
| 393 | }; | |
| 394 | function fillVars(html) { | |
| 395 | return (html || '').replace(/\{\{\s*([a-z_][a-z0-9_]*)\s*\}\}/gi, function (_, k) { | |
| 396 | return (k in SAMPLES) ? SAMPLES[k] : '{{' + k + '}}'; | |
| 397 | }); | |
| 398 | } | |
| 399 | var backdrop = document.getElementById('wprev-backdrop'); | |
| 400 | var prevBody = document.getElementById('wprev-body'); | |
| 401 | var prevSubj = document.getElementById('wprev-subject'); | |
| 402 | var prevBtn = document.getElementById('wyse-preview'); | |
| 403 | var closeBtn = document.getElementById('wprev-close'); | |
| 404 | function openPreview() { | |
| 405 | syncHidden(); | |
| 406 | prevBody.innerHTML = fillVars(source.value); | |
| 407 | var subjInput = document.querySelector('input[name=subject]'); | |
| 408 | prevSubj.textContent = fillVars(subjInput ? subjInput.value : ''); | |
| 409 | backdrop.classList.add('is-open'); | |
| 410 | document.body.style.overflow = 'hidden'; | |
| 411 | } | |
| 412 | function closePreview() { | |
| 413 | backdrop.classList.remove('is-open'); | |
| 414 | document.body.style.overflow = ''; | |
| 415 | } | |
| 416 | if (prevBtn) prevBtn.addEventListener('click', function (ev) { ev.preventDefault(); openPreview(); }); | |
| 417 | if (closeBtn) closeBtn.addEventListener('click', closePreview); | |
| 418 | backdrop.addEventListener('click', function (ev) { | |
| 419 | if (ev.target === backdrop) closePreview(); | |
| 420 | }); | |
| 421 | document.addEventListener('keydown', function (ev) { | |
| 422 | if (ev.key === 'Escape' && backdrop.classList.contains('is-open')) closePreview(); | |
| 423 | }); | |
| 424 | })(); | |
| 425 | </script> | |
| 426 | <label style="display:flex;align-items:center;gap:8px;font-size:13px;color:#cbd5e1;margin-bottom:18px"> | |
| 427 | <input type="checkbox" name="is_active" value="1" $checked> Active (template can be sent) | |
| 428 | </label> | |
| 429 | <button type="submit" class="btn btn-primary btn-lg">Save changes</button> | |
| 430 | </div> | |
| 431 | <aside style="background:rgba(20,20,30,.4);padding:16px 18px;border:1px solid rgba(255,255,255,.08);border-radius:12px;height:fit-content"> | |
| 432 | <h3 style="font-size:13px;text-transform:uppercase;letter-spacing:.05em;color:#94a3b8;margin:0 0 6px">Variables & sample values</h3> | |
| 433 | <p style="font-size:11px;color:#94a3b8;margin:0 0 12px;line-height:1.5">Drop \{\{name\}\} into the body or subject; at send time it's swapped for the real value. The samples below are what the <strong>Preview</strong> button uses.</p> | |
| 434 | <table style="font-size:11.5px;line-height:1.5;width:100%;border-collapse:collapse"> | |
| 435 | <tr><td style="padding:3px 6px 3px 0;color:#c4b5fd;font-family:ui-monospace,Consolas,Menlo,monospace;white-space:nowrap">\{\{display_name\}\}</td><td style="padding:3px 0;color:#cbd5e1">Shawn (Platform Owner)</td></tr> | |
| 436 | <tr><td style="padding:3px 6px 3px 0;color:#c4b5fd;font-family:ui-monospace,Consolas,Menlo,monospace;white-space:nowrap">\{\{first_name\}\}</td><td style="padding:3px 0;color:#cbd5e1">Shawn</td></tr> | |
| 437 | <tr><td style="padding:3px 6px 3px 0;color:#c4b5fd;font-family:ui-monospace,Consolas,Menlo,monospace;white-space:nowrap">\{\{company_name\}\}</td><td style="padding:3px 0;color:#cbd5e1">Shawn's Workspace</td></tr> | |
| 438 | <tr><td style="padding:3px 6px 3px 0;color:#c4b5fd;font-family:ui-monospace,Consolas,Menlo,monospace;white-space:nowrap">\{\{trial_ends_at\}\}</td><td style="padding:3px 0;color:#cbd5e1">2026-07-04 09:00:00</td></tr> | |
| 439 | <tr><td style="padding:3px 6px 3px 0;color:#c4b5fd;font-family:ui-monospace,Consolas,Menlo,monospace;white-space:nowrap">\{\{days_left\}\}</td><td style="padding:3px 0;color:#cbd5e1">7</td></tr> | |
| 440 | <tr><td style="padding:3px 6px 3px 0;color:#c4b5fd;font-family:ui-monospace,Consolas,Menlo,monospace;white-space:nowrap">\{\{billing_url\}\}</td><td style="padding:3px 0;color:#94a3b8;font-size:10.5px">.../billing.cgi</td></tr> | |
| 441 | <tr><td style="padding:3px 6px 3px 0;color:#c4b5fd;font-family:ui-monospace,Consolas,Menlo,monospace;white-space:nowrap">\{\{dashboard_url\}\}</td><td style="padding:3px 0;color:#94a3b8;font-size:10.5px">.../dashboard.cgi</td></tr> | |
| 442 | <tr><td style="padding:3px 6px 3px 0;color:#c4b5fd;font-family:ui-monospace,Consolas,Menlo,monospace;white-space:nowrap">\{\{unsubscribe_url\}\}</td><td style="padding:3px 0;color:#94a3b8;font-size:10.5px">.../unsubscribe.cgi?t=...</td></tr> | |
| 443 | <tr><td style="padding:3px 6px 3px 0;color:#c4b5fd;font-family:ui-monospace,Consolas,Menlo,monospace;white-space:nowrap">\{\{inviter_name\}\}</td><td style="padding:3px 0;color:#cbd5e1">Shawn</td></tr> | |
| 444 | <tr><td style="padding:3px 6px 3px 0;color:#c4b5fd;font-family:ui-monospace,Consolas,Menlo,monospace;white-space:nowrap">\{\{inviter_email\}\}</td><td style="padding:3px 0;color:#cbd5e1">shawn\@example.com</td></tr> | |
| 445 | <tr><td style="padding:3px 6px 3px 0;color:#c4b5fd;font-family:ui-monospace,Consolas,Menlo,monospace;white-space:nowrap">\{\{accept_url\}\}</td><td style="padding:3px 0;color:#94a3b8;font-size:10.5px">.../accept_invite.cgi</td></tr> | |
| 446 | <tr><td style="padding:3px 6px 3px 0;color:#c4b5fd;font-family:ui-monospace,Consolas,Menlo,monospace;white-space:nowrap">\{\{reset_url\}\}</td><td style="padding:3px 0;color:#94a3b8;font-size:10.5px">.../reset_password.cgi</td></tr> | |
| 447 | </table> | |
| 448 | <hr style="border:0;border-top:1px solid rgba(255,255,255,.08);margin:18px 0"> | |
| 449 | <h3 style="font-size:13px;text-transform:uppercase;letter-spacing:.05em;color:#94a3b8;margin:0 0 12px">Send test</h3> | |
| 450 | <p style="font-size:12px;color:#94a3b8;margin:0 0 10px">Renders with sample values + sends to:</p> | |
| 451 | </aside> | |
| 452 | </form> | |
| 453 | ||
| 454 | <form method="POST" action="/admin_emails.cgi" style="margin-top:24px;padding:16px;border:1px solid rgba(91,33,182,.3);background:rgba(91,33,182,.05);border-radius:10px"> | |
| 455 | <input type="hidden" name="act" value="send_test"> | |
| 456 | <input type="hidden" name="slug" value="$t->{slug}"> | |
| 457 | <label style="display:block;font-size:12px;text-transform:uppercase;letter-spacing:.05em;color:#cbd5e1;margin-bottom:6px">Send test to</label> | |
| 458 | <div style="display:flex;gap:8px"> | |
| 459 | <input class="input" type="email" name="test_to" value="$u->{email}" style="flex:1"> | |
| 460 | <button type="submit" class="btn btn-secondary">Send test →</button> | |
| 461 | </div> | |
| 462 | </form> | |
| 463 | </div>~; | |
| 464 | } else { | |
| 465 | $body = qq~<div class="page-pad"><p style="color:#fca5a5">Template not found.</p></div>~; | |
| 466 | } | |
| 467 | } else { | |
| 468 | my $rows = ''; | |
| 469 | my %cat_color = ( | |
| 470 | transactional => '#a78bfa', | |
| 471 | lifecycle => '#5aa9ff', | |
| 472 | product => '#34d399', | |
| 473 | marketing => '#fbbf24', | |
| 474 | ); | |
| 475 | foreach my $t (@templates) { | |
| 476 | my $sys = $t->{is_system} ? '<span style="font-size:10px;padding:1px 6px;border-radius:3px;background:rgba(139,92,246,.18);color:#c4b5fd">SYS</span>' : ''; | |
| 477 | my $active = $t->{is_active} ? '<span style="color:#86efac">active</span>' : '<span style="color:#94a3b8">inactive</span>'; | |
| 478 | my $col = $cat_color{$t->{category}} || '#94a3b8'; | |
| 479 | my $sn = _h($t->{name}); | |
| 480 | my $ss = _h($t->{subject}); | |
| 481 | $rows .= qq~ | |
| 482 | <tr style="border-top:1px solid rgba(255,255,255,.05);cursor:pointer" onclick="location.href='/admin_emails.cgi?edit=$t->{slug}'"> | |
| 483 | <td style="padding:10px 12px"><div style="font-weight:600;color:#e2e8f0">$sn $sys</div><div style="font-size:11px;color:#94a3b8;font-family:ui-monospace,Consolas,Menlo,monospace">$t->{slug}</div></td> | |
| 484 | <td style="padding:10px 12px"><span style="font-size:11px;color:$col;text-transform:uppercase;letter-spacing:.05em;font-weight:600">$t->{category}</span></td> | |
| 485 | <td style="padding:10px 12px;font-size:13px;color:#cbd5e1">$ss</td> | |
| 486 | <td style="padding:10px 12px;font-size:12px">$t->{sched_count} sched</td> | |
| 487 | <td style="padding:10px 12px;font-size:12px">$active</td> | |
| 488 | </tr>~; | |
| 489 | } | |
| 490 | my $sends_html = ''; | |
| 491 | foreach my $s (@recent_sends) { | |
| 492 | my $color = $s->{status} eq 'sent' ? '#86efac' | |
| 493 | : $s->{status} eq 'failed' ? '#fca5a5' | |
| 494 | : $s->{status} =~ /^suppressed/ ? '#fbbf24' | |
| 495 | : '#94a3b8'; | |
| 496 | my $to = _h($s->{to_email}); | |
| 497 | my $sj = _h($s->{subject}); | |
| 498 | my $rsn = _h($s->{failure_reason} || ''); | |
| 499 | $sends_html .= qq~ | |
| 500 | <tr style="border-top:1px solid rgba(255,255,255,.05)"> | |
| 501 | <td style="padding:6px 10px;font-size:11px;color:#94a3b8"><span class="ts" data-ts="$s->{created_at_epoch}" data-fmt="datetime">$s->{created_at}</span></td> | |
| 502 | <td style="padding:6px 10px;font-size:12px;color:#cbd5e1;font-family:ui-monospace,Consolas,Menlo,monospace">$s->{template_slug}</td> | |
| 503 | <td style="padding:6px 10px;font-size:12px;color:#cbd5e1">$to</td> | |
| 504 | <td style="padding:6px 10px;font-size:11px;color:$color;text-transform:uppercase;letter-spacing:.05em">$s->{status}</td> | |
| 505 | <td style="padding:6px 10px;font-size:11px;color:#94a3b8">$rsn</td> | |
| 506 | </tr>~; | |
| 507 | } | |
| 508 | my $tabs = _email_setup_tabs('templates'); | |
| 509 | $body = qq~ | |
| 510 | <div class="page-pad"> | |
| 511 | <h1 style="margin:0 0 6px;font-size:28px;color:#fff">Email Setup</h1> | |
| 512 | <p style="color:#94a3b8;margin:0 0 18px;font-size:14px">Templates, schedules, and spam-protection controls.</p> | |
| 513 | $tabs | |
| 514 | $flash_html | |
| 515 | <h2 style="font-size:18px;color:#fff;margin:0 0 6px">Templates</h2> | |
| 516 | <p style="color:#94a3b8;margin:0 0 14px;font-size:13px">Edit subject + body for every email AffSoft sends. Click a row to edit.</p> | |
| 517 | ||
| 518 | <div style="border:1px solid rgba(255,255,255,.08);border-radius:12px;overflow:hidden;background:rgba(20,20,30,.4);margin-bottom:32px"> | |
| 519 | <table style="width:100%;border-collapse:collapse"> | |
| 520 | <thead><tr> | |
| 521 | <th style="padding:10px 12px;text-align:left;font-size:11px;color:#94a3b8;text-transform:uppercase;letter-spacing:.05em">Name</th> | |
| 522 | <th style="padding:10px 12px;text-align:left;font-size:11px;color:#94a3b8;text-transform:uppercase;letter-spacing:.05em">Category</th> | |
| 523 | <th style="padding:10px 12px;text-align:left;font-size:11px;color:#94a3b8;text-transform:uppercase;letter-spacing:.05em">Subject</th> | |
| 524 | <th style="padding:10px 12px;text-align:left;font-size:11px;color:#94a3b8;text-transform:uppercase;letter-spacing:.05em">Schedules</th> | |
| 525 | <th style="padding:10px 12px;text-align:left;font-size:11px;color:#94a3b8;text-transform:uppercase;letter-spacing:.05em">Status</th> | |
| 526 | </tr></thead> | |
| 527 | <tbody>$rows</tbody> | |
| 528 | </table> | |
| 529 | </div> | |
| 530 | ||
| 531 | <h2 style="font-size:18px;color:#fff;margin:0 0 14px">Recent sends</h2> | |
| 532 | <div style="border:1px solid rgba(255,255,255,.08);border-radius:12px;overflow:hidden;background:rgba(20,20,30,.4)"> | |
| 533 | <table style="width:100%;border-collapse:collapse"> | |
| 534 | <thead><tr> | |
| 535 | <th style="padding:6px 10px;text-align:left;font-size:10px;color:#94a3b8;text-transform:uppercase">When</th> | |
| 536 | <th style="padding:6px 10px;text-align:left;font-size:10px;color:#94a3b8;text-transform:uppercase">Template</th> | |
| 537 | <th style="padding:6px 10px;text-align:left;font-size:10px;color:#94a3b8;text-transform:uppercase">To</th> | |
| 538 | <th style="padding:6px 10px;text-align:left;font-size:10px;color:#94a3b8;text-transform:uppercase">Status</th> | |
| 539 | <th style="padding:6px 10px;text-align:left;font-size:10px;color:#94a3b8;text-transform:uppercase">Detail</th> | |
| 540 | </tr></thead> | |
| 541 | <tbody>$sends_html</tbody> | |
| 542 | </table> | |
| 543 | </div> | |
| 544 | </div>~; | |
| 545 | } | |
| 546 | ||
| 547 | $wrap->render({ userinfo => $u, page_key => 'email_setup', title => 'Email Setup', body => $body }); | |
| 548 | ||
| 549 | sub _h { my $s = shift // ''; $s =~ s/&/&/g; $s =~ s/</</g; $s =~ s/>/>/g; $s =~ s/"/"/g; $s =~ s/'/'/g; return $s; } | |
| 550 | ||
| 551 | sub _email_setup_tabs { | |
| 552 | my ($active) = @_; | |
| 553 | my %t = (templates => 'Templates', schedules => 'Schedules', controls => 'Controls'); | |
| 554 | my %h = (templates => '/admin_emails.cgi', schedules => '/admin_email_schedules.cgi', controls => '/admin_email_controls.cgi'); | |
| 555 | my $html = '<div style="display:flex;gap:0;border-bottom:1px solid rgba(255,255,255,.08);margin:0 0 22px">'; | |
| 556 | foreach my $k (qw(templates schedules controls)) { | |
| 557 | my $is_active = ($k eq $active); | |
| 558 | my $color = $is_active ? '#5aa9ff' : '#94a3b8'; | |
| 559 | my $border = $is_active ? '#5aa9ff' : 'transparent'; | |
| 560 | my $weight = $is_active ? '600' : '500'; | |
| 561 | $html .= qq~<a href="$h{$k}" style="padding:10px 20px;color:$color;text-decoration:none;font-size:13px;border-bottom:2px solid $border;font-weight:$weight;letter-spacing:.02em">$t{$k}</a>~; | |
| 562 | } | |
| 563 | $html .= '</div>'; | |
| 564 | return $html; | |
| 565 | } |