Diff -- /var/www/vhosts/3dshawn.com/admin.3dshawn.com/canned.cgi
Diff

/var/www/vhosts/3dshawn.com/admin.3dshawn.com/canned.cgi

added on local at 2026-07-10 14:01:52

Added
+248
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to 46a4e75f7f14
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# 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#======================================================================
16use strict;
17use warnings;
18use lib '/var/www/vhosts/3dshawn.com/admin.3dshawn.com';
19use CGI ();
20use MODS::Login;
21use MODS::MetaAdmin::Config;
22use MODS::MetaAdmin::Wrapper;
23use MODS::MetaAdmin::CannedReplies;
24
25my $auth = MODS::Login->new;
26my $u = $auth->login_verify;
27unless ($u) { print "Status: 302 Found\nLocation: /login.cgi\n\n"; exit; }
28
29my $cgi = CGI->new;
30my $cr = MODS::MetaAdmin::CannedReplies->new;
31
32sub _esc { my $s=shift; $s//=''; $s=~s/&/&amp;/g; $s=~s/</&lt;/g; $s=~s/>/&gt;/g; $s=~s/"/&quot;/g; return $s; }
33
34#---------------------------------------------------------------------
35# POST handlers
36#---------------------------------------------------------------------
37my $method = $ENV{REQUEST_METHOD} || 'GET';
38if ($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}
52if ($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#---------------------------------------------------------------------
61my $edit = $cgi->param('edit') || '';
62if ($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> &larr; 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 &quot;{subject}&quot; -- 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#---------------------------------------------------------------------
143my $rows = $cr->list;
144my $saved = $cgi->param('saved') || '';
145my $toggled = $cgi->param('toggled') || '';
146
147my $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
155if ($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}
158if ($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
162my $tbl = '';
163if (!@$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
229my $count_active = scalar grep { $_->{is_active} } @$rows;
230my $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 &gt; 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
246MODS::MetaAdmin::Wrapper->new->render(
247 title => 'Canned Replies', page_key => 'canned', body => $body, userinfo => $u,
248);
Keyboard: j next diff k previous diff g top G bottom r restore c compile-check ? help