added on local at 2026-07-10 14:01:52
| 1 | #!/usr/bin/perl | |
| 2 | #====================================================================== | |
| 3 | # Meta-Admin -- /canned.cgi | |
| 4 | # | |
| 5 | # Manage the canned support-reply template library. Templates are used | |
| 6 | # by /support.cgi's reply form (dropdown insert) and later by the | |
| 7 | # auto-ack scheduler. | |
| 8 | # | |
| 9 | # Modes (query params): | |
| 10 | # /canned.cgi -> list view | |
| 11 | # /canned.cgi?edit=NEW -> new-template form | |
| 12 | # /canned.cgi?edit=ID -> edit existing template | |
| 13 | # POST with `save=1` -> insert or update, redirect to list | |
| 14 | # POST with `toggle=ID` -> flip is_active, redirect to list | |
| 15 | #====================================================================== | |
| 16 | use strict; | |
| 17 | use warnings; | |
| 18 | use lib '/var/www/vhosts/3dshawn.com/admin.3dshawn.com'; | |
| 19 | use CGI (); | |
| 20 | use MODS::Login; | |
| 21 | use MODS::MetaAdmin::Config; | |
| 22 | use MODS::MetaAdmin::Wrapper; | |
| 23 | use MODS::MetaAdmin::CannedReplies; | |
| 24 | ||
| 25 | my $auth = MODS::Login->new; | |
| 26 | my $u = $auth->login_verify; | |
| 27 | unless ($u) { print "Status: 302 Found\nLocation: /login.cgi\n\n"; exit; } | |
| 28 | ||
| 29 | my $cgi = CGI->new; | |
| 30 | my $cr = MODS::MetaAdmin::CannedReplies->new; | |
| 31 | ||
| 32 | sub _esc { my $s=shift; $s//=''; $s=~s/&/&/g; $s=~s/</</g; $s=~s/>/>/g; $s=~s/"/"/g; return $s; } | |
| 33 | ||
| 34 | #--------------------------------------------------------------------- | |
| 35 | # POST handlers | |
| 36 | #--------------------------------------------------------------------- | |
| 37 | my $method = $ENV{REQUEST_METHOD} || 'GET'; | |
| 38 | if ($method eq 'POST' && $cgi->param('save')) { | |
| 39 | my $id = int($cgi->param('id') || 0); | |
| 40 | my $ret = $cr->save( | |
| 41 | id => $id, | |
| 42 | name => scalar($cgi->param('name')), | |
| 43 | description => scalar($cgi->param('description')), | |
| 44 | body => scalar($cgi->param('body')), | |
| 45 | sort_order => scalar($cgi->param('sort_order')), | |
| 46 | is_active => $cgi->param('is_active') ? 1 : 0, | |
| 47 | ); | |
| 48 | my $err = $cr->last_error; | |
| 49 | my $q = $ret ? '?saved=' . $ret : '?edit=' . ($id || 'NEW') . '&err=' . $err; | |
| 50 | print "Status: 302 Found\nLocation: /canned.cgi$q\n\n"; exit; | |
| 51 | } | |
| 52 | if ($method eq 'POST' && $cgi->param('toggle')) { | |
| 53 | my $id = int($cgi->param('toggle')); | |
| 54 | $cr->toggle_active($id); | |
| 55 | print "Status: 302 Found\nLocation: /canned.cgi?toggled=$id\n\n"; exit; | |
| 56 | } | |
| 57 | ||
| 58 | #--------------------------------------------------------------------- | |
| 59 | # Edit mode | |
| 60 | #--------------------------------------------------------------------- | |
| 61 | my $edit = $cgi->param('edit') || ''; | |
| 62 | if ($edit) { | |
| 63 | my $row = ($edit eq 'NEW') ? {} : $cr->get($edit); | |
| 64 | $row ||= {}; | |
| 65 | my $id_h = _esc($row->{id} || ''); | |
| 66 | my $name_h = _esc($row->{name} || ''); | |
| 67 | my $desc_h = _esc($row->{description} || ''); | |
| 68 | my $body_h = _esc($row->{body} || ''); | |
| 69 | my $sort_h = _esc(defined $row->{sort_order} ? $row->{sort_order} : 100); | |
| 70 | my $active = (defined $row->{is_active} && $row->{is_active} == 0) ? '' : 'checked'; | |
| 71 | my $title = ($edit eq 'NEW') ? 'New canned reply' : "Edit template #$id_h"; | |
| 72 | my $err = _esc($cgi->param('err') || ''); | |
| 73 | my $err_h = $err ? qq~<div class="banner banner-err" style="margin:10px 0;padding:10px 14px;border-radius:10px;background:rgba(239,68,68,.10);border:1px solid rgba(239,68,68,.35);color:#fca5a5;font-size:13px">$err</div>~ : ''; | |
| 74 | ||
| 75 | my $body = qq~ | |
| 76 | <div class="page-head"><div class="left"> | |
| 77 | <a href="/canned.cgi" class="page-eyebrow" style="text-decoration:none"><span class="dot"></span> ← Back to library</a> | |
| 78 | <h1 class="page-title">$title</h1> | |
| 79 | <p class="page-subtitle">Use <code>{customer_name}</code>, <code>{customer_first}</code>, <code>{customer_email}</code>, <code>{subject}</code>, <code>{site_name}</code>, <code>{days_since_open}</code>, <code>{other_open_thread_count}</code>, <code>{other_open_thread_subjects}</code> as merge tokens. They're expanded at insert time.</p> | |
| 80 | </div></div> | |
| 81 | $err_h | |
| 82 | <style> | |
| 83 | .form-grid { display:grid; gap:14px; max-width:820px; } | |
| 84 | .form-grid label { display:block; font-size:11px; letter-spacing:1.4px; text-transform:uppercase; color:var(--col-text-dim); font-weight:700; margin-bottom:6px; } | |
| 85 | .form-grid input[type=text], .form-grid input[type=number], .form-grid textarea { | |
| 86 | width:100%; box-sizing:border-box; background:var(--col-surface); color:var(--col-text); | |
| 87 | border:1px solid var(--col-border); border-radius:10px; padding:10px 12px; | |
| 88 | font-family:inherit; font-size:13px; line-height:1.5; outline:none; | |
| 89 | transition:border-color .15s ease; | |
| 90 | } | |
| 91 | .form-grid textarea { min-height:180px; resize:vertical; } | |
| 92 | .form-grid input:focus, .form-grid textarea:focus { border-color:var(--col-brand, var(--col-primary, #b53048)); } | |
| 93 | .form-row-inline { display:flex; gap:14px; align-items:end; } | |
| 94 | .form-row-inline > * { flex:1; } | |
| 95 | .form-actions { display:flex; align-items:center; gap:12px; margin-top:8px; } | |
| 96 | .btn-primary { background:var(--col-brand, var(--col-primary, #b53048)); color:#fff; border:none; padding:10px 20px; border-radius:999px; font-weight:600; cursor:pointer; font-size:13px; } | |
| 97 | .btn-primary:hover { box-shadow:0 4px 14px rgba(181,48,72,.35); } | |
| 98 | .btn-secondary { background:transparent; color:var(--col-text-muted); border:1px solid var(--col-border); padding:10px 18px; border-radius:999px; font-size:13px; text-decoration:none; } | |
| 99 | </style> | |
| 100 | <form method="post" action="/canned.cgi" class="form-grid"> | |
| 101 | <input type="hidden" name="save" value="1"> | |
| 102 | <input type="hidden" name="id" value="$id_h"> | |
| 103 | <div> | |
| 104 | <label>Name (operator-facing, shown in dropdown)</label> | |
| 105 | <input type="text" name="name" value="$name_h" required maxlength="80" placeholder="e.g. Acknowledge new thread"> | |
| 106 | </div> | |
| 107 | <div> | |
| 108 | <label>Description (optional, shown in list view)</label> | |
| 109 | <input type="text" name="description" value="$desc_h" maxlength="255" placeholder="e.g. First-touch acknowledgment; customer knows we saw them"> | |
| 110 | </div> | |
| 111 | <div> | |
| 112 | <label>Body</label> | |
| 113 | <textarea name="body" required placeholder="Hi {customer_first}, thanks for reaching out about "{subject}" -- we're looking into this and will circle back shortly.">$body_h</textarea> | |
| 114 | </div> | |
| 115 | <div class="form-row-inline"> | |
| 116 | <div> | |
| 117 | <label>Sort order (lower = appears higher in dropdown)</label> | |
| 118 | <input type="number" name="sort_order" value="$sort_h" min="0" max="9999"> | |
| 119 | </div> | |
| 120 | <div> | |
| 121 | <label>Active</label> | |
| 122 | <label style="display:flex;align-items:center;gap:8px;font-size:13px;color:var(--col-text);text-transform:none;letter-spacing:0;font-weight:400"> | |
| 123 | <input type="checkbox" name="is_active" $active> Show in dropdown + eligible for auto-ack | |
| 124 | </label> | |
| 125 | </div> | |
| 126 | </div> | |
| 127 | <div class="form-actions"> | |
| 128 | <button type="submit" class="btn-primary">Save template</button> | |
| 129 | <a href="/canned.cgi" class="btn-secondary">Cancel</a> | |
| 130 | </div> | |
| 131 | </form> | |
| 132 | ~; | |
| 133 | ||
| 134 | MODS::MetaAdmin::Wrapper->new->render( | |
| 135 | title => $title, page_key => 'canned', body => $body, userinfo => $u, | |
| 136 | ); | |
| 137 | exit; | |
| 138 | } | |
| 139 | ||
| 140 | #--------------------------------------------------------------------- | |
| 141 | # List mode | |
| 142 | #--------------------------------------------------------------------- | |
| 143 | my $rows = $cr->list; | |
| 144 | my $saved = $cgi->param('saved') || ''; | |
| 145 | my $toggled = $cgi->param('toggled') || ''; | |
| 146 | ||
| 147 | my $body = q~ | |
| 148 | <div class="page-head"><div class="left"> | |
| 149 | <span class="page-eyebrow"><span class="dot"></span> Library</span> | |
| 150 | <h1 class="page-title">Canned Replies</h1> | |
| 151 | <p class="page-subtitle">Templates the operator can drop into support-thread replies with one click. Body may contain merge tokens (see the New/Edit form for the full list) which are expanded at insert time using the thread's real context. These same templates will power the auto-acknowledge scheduler once that's wired.</p> | |
| 152 | </div></div> | |
| 153 | ~; | |
| 154 | ||
| 155 | if ($saved) { | |
| 156 | $body .= qq~<div class="banner banner-ok" style="margin:10px 0;padding:10px 14px;border-radius:10px;background:rgba(34,197,94,.10);border:1px solid rgba(34,197,94,.35);color:#a7f3d0;font-size:13px">Saved template #$saved.</div>~; | |
| 157 | } | |
| 158 | if ($toggled) { | |
| 159 | $body .= qq~<div class="banner banner-ok" style="margin:10px 0;padding:10px 14px;border-radius:10px;background:rgba(6,182,212,.10);border:1px solid rgba(6,182,212,.35);color:#7dd3fc;font-size:13px">Toggled active state on #$toggled.</div>~; | |
| 160 | } | |
| 161 | ||
| 162 | my $tbl = ''; | |
| 163 | if (!@$rows) { | |
| 164 | $tbl = q~<div style="padding:40px 14px;text-align:center;color:var(--col-text-dim);font-size:13px">No templates yet. Create one with the button above.</div>~; | |
| 165 | } else { | |
| 166 | $tbl .= q~ | |
| 167 | <style> | |
| 168 | .tpl-list { display:grid; row-gap:2px; font-size:13px; } | |
| 169 | .tpl-row { display:grid; grid-template-columns: 60px 1.6fr 3fr 90px 100px; gap:14px; align-items:center; | |
| 170 | padding:12px 14px; border-bottom:1px solid rgba(255,255,255,.03); transition:background .12s ease; } | |
| 171 | .tpl-row:hover { background:rgba(255,255,255,.02); } | |
| 172 | .tpl-row.inactive { opacity:.5; } | |
| 173 | .tpl-sort { color:var(--col-text-dim); font-family:'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace; font-size:11px; } | |
| 174 | .tpl-name { font-weight:600; color:var(--col-text); } | |
| 175 | .tpl-desc { color:var(--col-text-dim); font-size:12px; margin-top:2px; } | |
| 176 | .tpl-body-preview { color:var(--col-text-2); font-size:12.5px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; font-family:'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace; } | |
| 177 | .tpl-status { text-align:center; font-size:10.5px; letter-spacing:.6px; text-transform:uppercase; padding:3px 8px; border-radius:999px; font-weight:700; } | |
| 178 | .tpl-status.on { background:rgba(34,197,94,.10); color:#a7f3d0; border:1px solid rgba(34,197,94,.35); } | |
| 179 | .tpl-status.off { background:rgba(255,255,255,.04); color:var(--col-text-dim); border:1px solid var(--col-border); } | |
| 180 | .tpl-actions { display:flex; gap:6px; justify-content:flex-end; } | |
| 181 | .tpl-actions a, .tpl-actions button { background:transparent; border:1px solid var(--col-border); color:var(--col-text-muted); padding:5px 10px; border-radius:6px; font-size:11px; cursor:pointer; text-decoration:none; font-family:inherit; } | |
| 182 | .tpl-actions a:hover, .tpl-actions button:hover { color:var(--col-text); border-color:var(--col-brand, var(--col-primary, #b53048)); } | |
| 183 | .btn-new { background:var(--col-brand, var(--col-primary, #b53048)); color:#fff; border:none; padding:8px 16px; border-radius:999px; font-weight:600; font-size:12px; text-decoration:none; display:inline-flex; align-items:center; gap:6px; } | |
| 184 | .btn-new:hover { box-shadow:0 4px 14px rgba(181,48,72,.35); } | |
| 185 | </style>~; | |
| 186 | ||
| 187 | my $newbtn = '<a class="btn-new" href="/canned.cgi?edit=NEW">+ New template</a>'; | |
| 188 | $tbl .= qq~ | |
| 189 | <div class="module glow-magenta"> | |
| 190 | <div class="module-head"> | |
| 191 | <div class="left"> | |
| 192 | <div class="module-icon"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg></div> | |
| 193 | <div class="module-title">Templates</div> | |
| 194 | <span class="module-subtitle">Sort-order low \x{2192} high controls dropdown order. Inactive templates stay in the DB but don't appear in the reply form.</span> | |
| 195 | </div> | |
| 196 | <div class="right" style="padding-right:14px">$newbtn</div> | |
| 197 | </div> | |
| 198 | <div class="module-body" style="padding:0"><div class="tpl-list">~; | |
| 199 | foreach my $r (@$rows) { | |
| 200 | my $cls = $r->{is_active} ? '' : ' inactive'; | |
| 201 | my $id_h = _esc($r->{id}); | |
| 202 | my $sort_h = _esc($r->{sort_order}); | |
| 203 | my $name_h = _esc($r->{name}); | |
| 204 | my $desc_h = _esc($r->{description} || ''); | |
| 205 | my $preview = _esc(substr($r->{body} || '', 0, 140)); | |
| 206 | $preview =~ s/\s+/ /g; | |
| 207 | my ($sc, $sl) = $r->{is_active} ? ('on','ACTIVE') : ('off','OFF'); | |
| 208 | $tbl .= qq~ | |
| 209 | <div class="tpl-row$cls"> | |
| 210 | <div class="tpl-sort">#$sort_h</div> | |
| 211 | <div> | |
| 212 | <div class="tpl-name">$name_h</div> | |
| 213 | <div class="tpl-desc">$desc_h</div> | |
| 214 | </div> | |
| 215 | <div class="tpl-body-preview">$preview</div> | |
| 216 | <div class="tpl-status $sc">$sl</div> | |
| 217 | <div class="tpl-actions"> | |
| 218 | <a href="/canned.cgi?edit=$id_h">Edit</a> | |
| 219 | <form method="post" action="/canned.cgi" style="display:inline"> | |
| 220 | <input type="hidden" name="toggle" value="$id_h"> | |
| 221 | <button type="submit">Toggle</button> | |
| 222 | </form> | |
| 223 | </div> | |
| 224 | </div>~; | |
| 225 | } | |
| 226 | $tbl .= q~</div></div></div>~; | |
| 227 | } | |
| 228 | ||
| 229 | my $count_active = scalar grep { $_->{is_active} } @$rows; | |
| 230 | my $count_total = scalar @$rows; | |
| 231 | ||
| 232 | $body .= qq~ | |
| 233 | <div class="dash-grid" style="grid-template-columns: repeat(2, 1fr); margin-bottom:16px"> | |
| 234 | <div class="module glow-magenta"> | |
| 235 | <div class="module-head"><div class="left"><div class="module-icon"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg></div><div class="module-title">Active templates</div><span class="module-subtitle">Show up in the /support.cgi reply dropdown right now.</span></div></div> | |
| 236 | <div class="module-body kpi kpi-magenta"><div class="num">$count_active</div><div class="lbl">of $count_total total</div><div class="sub">ready to use</div></div> | |
| 237 | </div> | |
| 238 | <div class="module glow-cyan"> | |
| 239 | <div class="module-head"><div class="left"><div class="module-icon"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg></div><div class="module-title">Ethical guardrail</div><span class="module-subtitle">Templates are for <em>acknowledgment</em>, never <em>answering</em>. If a template tries to give the customer a specific answer, rewrite it as a hand-raise. That's the rule that keeps auto-ack safe when we wire it later.</span></div></div> | |
| 240 | <div class="module-body kpi kpi-cyan"><div class="num">Ack > Answer</div><div class="lbl">design principle</div><div class="sub">reply fast, resolve slow</div></div> | |
| 241 | </div> | |
| 242 | </div> | |
| 243 | $tbl | |
| 244 | ~; | |
| 245 | ||
| 246 | MODS::MetaAdmin::Wrapper->new->render( | |
| 247 | title => 'Canned Replies', page_key => 'canned', body => $body, userinfo => $u, | |
| 248 | ); |