Diff -- /var/www/vhosts/3dshawn.com/abforge.3dshawn.com/TEMPLATES/abforge_support.html

O Operator
Diff

/var/www/vhosts/3dshawn.com/abforge.3dshawn.com/TEMPLATES/abforge_support.html

added on local at 2026-07-01 16:01:57

Added
+591
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to 7df065286a76
Restore this content →
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.
1<!-- =====================================================================
2 CUSTOMER SUPPORT CENTER
3 /support.cgi -- the customer's view of their support threads with
4 the ABForge team.
5
6 Two-pane layout:
7 Left -- scrollable thread list with unread badges
8 Right -- active conversation + reply form (or empty state)
9
10 Async messaging only. Live-chat UI was removed; the conversation
11 polls for new admin replies every 12s.
12
13 Theme-aware via var(--col-*) so this works under every dashboard
14 color theme.
15===================================================================== -->
16<style>
17 .sc-page { display:grid;grid-template-columns:340px 1fr;gap:20px;height:calc(100vh - 100px);min-height:560px;padding:0;margin:0 -8px; }
18 .sc-pane { background:var(--col-surface-1);border:1px solid var(--col-border);border-radius:14px;overflow:hidden;display:flex;flex-direction:column;min-height:0; }
19
20 /* Left pane: thread list */
21 .sc-list-head { padding:16px 18px;border-bottom:1px solid var(--col-border);display:flex;align-items:center;justify-content:space-between;gap:10px;flex-shrink:0; }
22 .sc-list-title { font-family:var(--font-display);font-size:18px;color:var(--col-text);margin:0; }
23 .sc-list-sub { font-size:11px;color:var(--col-text-3);letter-spacing:1px;text-transform:uppercase; }
24 .sc-new-btn { padding:8px 14px;background:linear-gradient(135deg,var(--col-accent),var(--col-accent-deep));color:#fff;border:none;border-radius:8px;font-size:12px;font-weight:700;text-decoration:none;display:inline-flex;align-items:center;gap:6px;cursor:pointer; }
25 .sc-new-btn:hover { filter:brightness(1.08); }
26 .sc-list { flex:1;overflow-y:auto;padding:8px; }
27 .sc-list-empty { padding:50px 22px;text-align:center;color:var(--col-text-3);font-size:13px;line-height:1.6; }
28 /* Compact one-line thread rows -- everything on a single horizontal
29 flex line so the inbox fits 3-4x more threads per viewport. Same
30 anatomy as the admin support inbox so the two surfaces feel like
31 mirrors of each other. */
32 .sc-thread { display:flex;align-items:center;gap:8px;padding:6px 12px;border-radius:8px;text-decoration:none;color:inherit;margin-bottom:2px;border:1px solid transparent;transition:background 0.15s,border-color 0.15s;min-height:32px; }
33 .sc-thread:hover { background:var(--col-surface-2);border-color:var(--col-border); }
34 .sc-thread.is-active { background:var(--col-accent-glow);border-color:var(--col-accent); }
35 .sc-thread-subj { font-size:13px;font-weight:600;color:var(--col-text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;line-height:1.3;flex:1;min-width:0; }
36 .sc-thread-time { font-size:11px;color:var(--col-text-3);flex-shrink:0; }
37 .sc-pill { padding:1px 7px;border-radius:10px;font-size:10px;font-weight:700;letter-spacing:0.4px;flex-shrink:0; }
38 .sc-pill.st-open { background:rgba(255,122,61,0.18);color:#ffb347;border:1px solid rgba(255,122,61,0.36); }
39 .sc-pill.st-waiting { background:rgba(245,158,11,0.15);color:#fbbf24;border:1px solid rgba(245,158,11,0.32); }
40 .sc-pill.st-resolved { background:rgba(34,197,94,0.15);color:#4ade80;border:1px solid rgba(34,197,94,0.32); }
41 .sc-pill.st-closed { background:rgba(148,163,184,0.15);color:#cbd5e1;border:1px solid rgba(148,163,184,0.32); }
42 .sc-pill.chat { background:rgba(124,58,237,0.18);color:#c4b5fd;border:1px solid rgba(124,58,237,0.36); }
43 .sc-unread { min-width:18px;height:18px;padding:0 5px;border-radius:9px;background:#ef4444;color:#fff;font-size:10px;font-weight:800;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0; }
44
45 /* Right pane: active conversation */
46 .sc-conv { display:flex;flex-direction:column;flex:1;min-height:0; }
47 .sc-conv-head { padding:14px 20px;border-bottom:1px solid var(--col-border);display:flex;align-items:center;justify-content:space-between;gap:14px;flex-shrink:0; }
48 .sc-conv-title { font-family:var(--font-display);font-size:18px;color:var(--col-text);margin:0;display:flex;align-items:center;gap:10px;min-width:0; }
49 .sc-conv-title span { overflow:hidden;text-overflow:ellipsis;white-space:nowrap; }
50 .sc-conv-actions { display:flex;gap:8px;flex-shrink:0; }
51 .sc-btn { padding:7px 12px;background:var(--col-surface-2);border:1px solid var(--col-border-2);color:var(--col-text);border-radius:7px;font-size:12px;font-weight:600;cursor:pointer;font-family:inherit; }
52 .sc-btn:hover { background:var(--col-accent-glow);border-color:var(--col-accent); }
53 .sc-btn.is-danger { background:rgba(239,68,68,0.12);border-color:rgba(239,68,68,0.4);color:#f87171; }
54 .sc-btn.is-danger:hover { background:rgba(239,68,68,0.22); }
55 .sc-btn.is-primary { background:linear-gradient(135deg,var(--col-accent),var(--col-accent-deep));border:none;color:#fff; }
56 .sc-btn.is-primary:hover { filter:brightness(1.08); }
57
58 .sc-stream { flex:1;overflow-y:auto;padding:18px 22px;display:flex;flex-direction:column;gap:14px; }
59
60 .sc-msg { display:flex;gap:10px;max-width:78%; }
61 .sc-msg.is-customer { margin-left:auto;flex-direction:row-reverse; }
62 .sc-msg.is-system { margin:0 auto;max-width:90%; }
63 .sc-avatar { width:34px;height:34px;border-radius:50%;background:linear-gradient(135deg,var(--col-accent),var(--col-accent-deep));color:#fff;display:flex;align-items:center;justify-content:center;font-family:var(--font-display);font-size:14px;flex-shrink:0; }
64 .sc-msg.is-customer .sc-avatar { background:linear-gradient(135deg,#10b981,#059669); }
65 .sc-msg.is-system .sc-avatar { background:var(--col-surface-2);color:var(--col-text-3);border:1px solid var(--col-border); }
66 .sc-bubble { background:var(--col-surface-2);border:1px solid var(--col-border);border-radius:14px;padding:10px 14px;font-size:13.5px;line-height:1.55;color:var(--col-text); }
67 .sc-msg.is-customer .sc-bubble { background:var(--col-accent-glow);border-color:var(--col-accent); }
68 .sc-msg.is-system .sc-bubble { background:transparent;border:1px dashed var(--col-border-2);color:var(--col-text-3);font-style:italic;font-size:12.5px;text-align:center; }
69 .sc-meta-line { font-size:10.5px;color:var(--col-text-3);margin-top:4px;letter-spacing:0.3px; }
70 .sc-msg.is-customer .sc-meta-line { text-align:right; }
71
72 .sc-compose { border-top:1px solid var(--col-border);padding:14px 18px;flex-shrink:0;background:var(--col-surface-2); }
73 .sc-compose textarea { width:100%;box-sizing:border-box;min-height:60px;max-height:200px;padding:10px 12px;background:var(--col-bg);border:1px solid var(--col-border-2);border-radius:8px;color:var(--col-text);font-family:inherit;font-size:13.5px;resize:vertical; }
74 .sc-compose textarea:focus { outline:none;border-color:var(--col-accent); }
75 .sc-compose-row { display:flex;justify-content:space-between;align-items:center;margin-top:8px;gap:10px; }
76 .sc-compose-hint { font-size:11px;color:var(--col-text-3); }
77
78 .sc-invite { padding:12px 16px;margin:12px 22px 0;background:rgba(124,58,237,0.12);border:1px solid rgba(124,58,237,0.4);border-radius:10px;display:flex;align-items:center;justify-content:space-between;gap:14px; }
79 .sc-invite-text { font-size:13px;color:var(--col-text); }
80 .sc-invite-text strong { color:#c4b5fd; }
81
82 .sc-chat-ribbon { padding:10px 16px;margin:12px 22px 0;background:rgba(34,197,94,0.10);border:1px solid rgba(34,197,94,0.4);border-radius:10px;display:flex;align-items:center;justify-content:space-between;gap:14px;font-size:12.5px;color:#4ade80; }
83 .sc-chat-ribbon .dot { width:8px;height:8px;border-radius:50%;background:#4ade80;animation:scPulse 1.4s infinite ease-in-out;display:inline-block;margin-right:8px;box-shadow:0 0 8px #4ade80; }
84 @keyframes scPulse { 0%, 100% { opacity:1 } 50% { opacity:0.35 } }
85
86 .sc-empty { flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:40px;color:var(--col-text-3);text-align:center;gap:14px; }
87 .sc-empty h2 { font-family:var(--font-display);font-size:22px;color:var(--col-text);margin:0; }
88 .sc-empty p { margin:0;max-width:380px;line-height:1.6;font-size:13.5px; }
89
90 /* New-request modal */
91 .sc-modal { position:fixed;inset:0;z-index:1000;background:rgba(3,8,31,0.78);backdrop-filter:blur(6px);display:none;align-items:flex-start;justify-content:center;padding:60px 20px;overflow-y:auto; }
92 .sc-modal.is-open { display:flex; }
93 .sc-modal-card { max-width:920px;width:100%;background:var(--col-surface-1);border:1px solid var(--col-border);border-radius:14px;padding:30px 34px; }
94 .sc-modal-title { font-family:var(--font-display);font-size:26px;color:var(--col-text);margin:0 0 8px; }
95 .sc-modal-sub { font-size:12px;letter-spacing:1.5px;text-transform:uppercase;color:var(--col-text-3);margin-bottom:14px; }
96 .sc-modal-hint { font-size:13.5px;color:var(--col-text-2);line-height:1.6;margin:0 0 18px;max-width:680px; }
97 .sc-modal label { display:block;font-size:12px;font-weight:600;color:var(--col-text-2);margin-bottom:6px;margin-top:14px;letter-spacing:0.3px;text-transform:uppercase; }
98 .sc-modal input[type=text], .sc-modal textarea { width:100%;box-sizing:border-box;padding:14px 16px;background:var(--col-bg);border:1px solid var(--col-border-2);border-radius:10px;color:var(--col-text);font-family:inherit;font-size:14.5px;line-height:1.55; }
99 .sc-modal input[type=text]:focus, .sc-modal textarea:focus { outline:none;border-color:var(--col-accent);box-shadow:0 0 0 3px rgba(255,122,61,0.18); }
100 .sc-modal textarea { min-height:360px;resize:vertical; }
101 .sc-modal-actions { display:flex;gap:10px;margin-top:22px;justify-content:flex-end; }
102
103 /* Attachment widget: drag/drop + paste + file picker, shared by the
104 new-thread modal and the reply form so customers and admins have
105 the same upload experience everywhere. */
106 .sup-att-area { margin-top: 12px; }
107 .sup-att-strip { display:flex;flex-wrap:wrap;gap:8px;margin-bottom:10px; }
108 .sup-att-strip:empty { display:none; }
109 .sup-att-thumb { position:relative;width:78px;height:78px;border-radius:8px;overflow:hidden;border:1px solid rgba(255,255,255,0.18);background:rgba(0,0,0,0.30); }
110 .sup-att-thumb img { width:100%;height:100%;object-fit:cover;display:block; }
111 .sup-att-thumb.is-pending { opacity:0.55; }
112 .sup-att-thumb.is-pending::after { content:'';position:absolute;inset:0;background:linear-gradient(110deg, transparent 30%, rgba(255,255,255,0.18) 50%, transparent 70%);animation:sup-att-shimmer 1.1s linear infinite; }
113 .sup-att-thumb.failed { border-color:#ef4444;opacity:0.7; }
114 .sup-att-thumb.failed::after { content:'!';position:absolute;inset:0;display:grid;place-items:center;color:#fff;font-weight:800;background:rgba(239,68,68,0.55); }
115 @keyframes sup-att-shimmer { 0% { transform:translateX(-100%); } 100% { transform:translateX(100%); } }
116 .sup-att-rm { position:absolute;top:3px;right:3px;width:20px;height:20px;border-radius:50%;background:rgba(0,0,0,0.78);color:#fff;border:none;cursor:pointer;line-height:18px;font-size:16px;padding:0;display:grid;place-items:center; }
117 .sup-att-rm:hover { background:#ef4444; }
118 .sup-att-controls { display:flex;align-items:center;gap:10px;flex-wrap:wrap; }
119 .sup-att-pick { display:inline-flex;align-items:center;gap:6px;padding:7px 13px;background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.18);color:var(--col-text-2);font-size:12px;font-weight:600;border-radius:8px;cursor:pointer;transition:background 0.15s, color 0.15s, border-color 0.15s; }
120 .sup-att-pick:hover { background:rgba(255,122,61,0.18);color:#fff;border-color:rgba(255,122,61,0.55); }
121 .sup-att-pick svg { width:14px;height:14px; }
122 /* Visually hide the file input but keep it focusable + clickable via
123 its parent <label>. display:none would block the file picker on
124 some mobile browsers; this positions it out of flow instead. */
125 .sup-att-pick input[type="file"] { position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0; }
126 .sup-att-hint { font-size:11px;color:var(--col-text-3); }
127 .sup-att-form.att-drag { outline:2px dashed var(--col-accent);outline-offset:4px;border-radius:12px; }
128 /* Live counter chip that appears the moment files are picked, so
129 the user gets immediate confirmation even before thumbs render. */
130 .sup-att-count { display:inline-flex;align-items:center;gap:6px;padding:5px 11px;border-radius:999px;font-size:11px;font-weight:700;letter-spacing:0.3px;text-transform:uppercase; }
131 .sup-att-count.is-working { background:rgba(245,158,11,0.18);border:1px solid rgba(245,158,11,0.45);color:#fde68a; }
132 .sup-att-count.is-ready { background:rgba(16,185,129,0.18);border:1px solid rgba(16,185,129,0.55);color:#6ee7b7; }
133 .sup-att-count.is-working::before { content:'';width:8px;height:8px;border-radius:50%;background:#f59e0b;animation:sup-att-blink 1s ease-in-out infinite; }
134 .sup-att-count.is-ready::before { content:'\2713';font-weight:800;color:#10b981; }
135 @keyframes sup-att-blink { 0%,100% { opacity:1; } 50% { opacity:0.35; } }
136
137 /* Inline-rendered attachments inside a message bubble. */
138 .sup-msg-atts { display:flex;flex-wrap:wrap;gap:6px;margin-top:8px; }
139 .sup-msg-att { display:block;border-radius:8px;overflow:hidden;border:1px solid rgba(255,255,255,0.15);transition:border-color 0.12s,transform 0.12s; }
140 .sup-msg-att:hover { border-color:var(--col-accent);transform:translateY(-1px); }
141 .sup-msg-att img { display:block;max-width:280px;max-height:200px;width:auto;height:auto; }
142 @media (max-width: 720px) {
143 .sc-modal-card { padding:22px 18px; }
144 .sc-modal textarea { min-height:240px; }
145 }
146
147 @media (max-width:1000px) {
148 .sc-page { grid-template-columns:1fr;height:auto; }
149 .sc-pane.sc-left { max-height:340px; }
150 }
151</style>
152
153
154<div class="page-head">
155 <div>
156 <span class="page-eyebrow"><span class="dot"></span> Support</span>
157 <h1 class="page-title">Help &amp; messages</h1>
158 <p class="page-subtitle">Send a question to the ABForge team and we&#39;ll reply right here. We aim to respond within one business day.</p>
159 </div>
160</div>
161
162[if:$just_opened]
163<div class="banner" style="margin-bottom:18px;background:rgba(34,197,94,0.10);border:1px solid rgba(34,197,94,0.40);color:#4ade80;border-radius:10px;padding:12px 14px">
164 <strong>Request submitted.</strong> A team member will reply here as soon as we can.
165</div>
166[/if]
167
168<div class="sc-page">
169
170 <!-- LEFT: thread list -->
171 <div class="sc-pane sc-left">
172 <div class="sc-list-head">
173 <div>
174 <div class="sc-list-sub">Your threads</div>
175 <h2 class="sc-list-title">$thread_count active</h2>
176 </div>
177 <button type="button" class="sc-new-btn" data-sc-open="sc-new">+ New</button>
178 </div>
179 <div class="sc-list">
180 [if:!$has_threads]
181 <div class="sc-list-empty">
182 No conversations yet.<br><br>
183 <button type="button" class="sc-new-btn" data-sc-open="sc-new">Start your first support request</button>
184 </div>
185 [/if]
186 [if:$has_threads]
187 [loop:@threads]
188 <a class="sc-thread[if:$loop1.is_active] is-active[/if]" href="/support.cgi?thread_id=$loop1.id" title="$loop1.subject -- $loop1.status_label">
189 <span class="sc-thread-subj">$loop1.subject</span>
190 <span class="sc-pill $loop1.status_class">$loop1.status_label</span>
191 [if:$loop1.has_unread]<span class="sc-unread">$loop1.unread</span>[/if]
192 <span class="sc-thread-time">$loop1.last_at</span>
193 </a>
194 [/loop]
195 [/if]
196 </div>
197 </div>
198
199 <!-- RIGHT: active conversation -->
200 <div class="sc-pane sc-right">
201 [if:!$has_active]
202 <div class="sc-empty">
203 <div style="font-size:48px;opacity:0.5">&#9993;</div>
204 <h2>No conversation selected</h2>
205 <p>Pick a thread from the left to read replies, or start a new support request and the ABForge team will get back to you.</p>
206 <button type="button" class="sc-new-btn" data-sc-open="sc-new">+ Start a new request</button>
207 </div>
208 [/if]
209
210 [if:$has_active]
211 <div class="sc-conv">
212 <div class="sc-conv-head">
213 <div class="sc-conv-title">
214 <span class="sc-pill $active_status_class" style="font-size:10px">$active_status</span>
215 <span>$active_subject</span>
216 </div>
217 </div>
218
219 <div class="sc-stream" id="sc-stream" data-thread-id="$active_id" data-last-id="$last_message_id" data-side="customer">
220 [loop:@messages]
221 <div class="sc-msg[if:$loop1.is_customer] is-customer[/if][if:$loop1.is_admin] is-admin[/if][if:$loop1.is_system] is-system[/if]" data-msg-id="$loop1.id">
222 [if:!$loop1.is_system]<div class="sc-avatar">$loop1.sender_initial</div>[/if]
223 [if:$loop1.is_system]<div class="sc-avatar">&#9432;</div>[/if]
224 <div>
225 <div class="sc-bubble">$loop1.body_html</div>
226 [if:$loop1.has_attach]
227 <div class="sup-msg-atts">
228 [loop:@attachments]
229 <a href="$loop2.url" target="_blank" rel="noopener" class="sup-msg-att" title="$loop2.name"><img src="$loop2.url" alt=""></a>
230 [/loop]
231 </div>
232 [/if]
233 <div class="sc-meta-line">$loop1.sender_name &middot; $loop1.ts</div>
234 </div>
235 </div>
236 [/loop]
237 </div>
238
239 <form class="sc-compose sup-att-form" id="sc-compose" data-thread-id="$active_id">
240 <textarea name="body" id="sc-body" placeholder="Write a message..." required></textarea>
241 <div class="sup-att-area" data-att-area>
242 <div class="sup-att-strip" data-att-strip></div>
243 <div class="sup-att-controls">
244 <label class="sup-att-pick" data-att-pick>
245 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
246 <span>Attach image</span>
247 <input type="file" data-att-input accept="image/jpeg,image/png,image/gif,image/webp,image/bmp,.jpg,.jpeg,.png,.gif,.webp,.bmp" multiple>
248 </label>
249 <span class="sup-att-hint">Drag &amp; drop or paste also works</span>
250 </div>
251 </div>
252 <div class="sc-compose-row">
253 <span class="sc-compose-hint">Enter to send &middot; Shift+Enter for a new line</span>
254 <button type="submit" class="sc-btn is-primary">Send</button>
255 </div>
256 </form>
257 </div>
258 [/if]
259 </div>
260</div>
261
262
263<!-- NEW REQUEST MODAL -->
264<div class="sc-modal[if:$show_new_form] is-open[/if]" id="sc-new" role="dialog" aria-modal="true">
265 <div class="sc-modal-card">
266 <div class="sc-modal-sub">New support request</div>
267 <h2 class="sc-modal-title">Tell us what&#39;s happening</h2>
268 <p class="sc-modal-hint">Take your time. The more context you give us &mdash; what you tried, screenshots, links, error text &mdash; the faster we can come back with an answer that actually fits your situation.</p>
269 [if:$has_new_err]
270 <div class="banner warning" style="margin-bottom:12px">Subject and message are both required.</div>
271 [/if]
272 <form method="post" action="/support.cgi" autocomplete="off" class="sup-att-form">
273 <input type="hidden" name="action" value="create_thread">
274 <label for="sc-subj">Subject</label>
275 <input id="sc-subj" type="text" name="subject" required maxlength="200" placeholder="Brief summary of the issue or question">
276 <label for="sc-body-new">Message</label>
277 <textarea id="sc-body-new" name="body" required placeholder="What&#39;s going on? Walk us through it &mdash; what you were trying to do, what happened instead, any error text you saw. Anything you think might be relevant. We read everything."></textarea>
278 <div class="sup-att-area" data-att-area>
279 <label style="display:block;margin-top:14px">Attachments</label>
280 <div class="sup-att-strip" data-att-strip></div>
281 <div class="sup-att-controls">
282 <label class="sup-att-pick" data-att-pick>
283 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
284 <span>Attach images</span>
285 <input type="file" data-att-input accept="image/jpeg,image/png,image/gif,image/webp,image/bmp,.jpg,.jpeg,.png,.gif,.webp,.bmp" multiple>
286 </label>
287 <span class="sup-att-hint">Or drag &amp; drop, or paste (JPG / PNG / GIF / WebP / BMP &middot; 10MB max)</span>
288 </div>
289 </div>
290 <div class="sc-modal-actions">
291 <button type="button" class="sc-btn" data-sc-close>Cancel</button>
292 <button type="submit" class="sc-btn is-primary">Send request</button>
293 </div>
294 </form>
295 </div>
296</div>
297
298
299<script>
300(function(){
301 // Modal open/close
302 document.addEventListener('click', function(e){
303 var open = e.target.closest('[data-sc-open]');
304 if (open) { var m = document.getElementById(open.getAttribute('data-sc-open')); if (m) m.classList.add('is-open'); e.preventDefault(); return; }
305 var close = e.target.closest('[data-sc-close]');
306 if (close) { var m2 = close.closest('.sc-modal'); if (m2) m2.classList.remove('is-open'); return; }
307 if (e.target.classList && e.target.classList.contains('sc-modal')) { e.target.classList.remove('is-open'); }
308 });
309
310 var stream = document.getElementById('sc-stream');
311 if (!stream) return; // no active thread
312
313 var threadId = stream.getAttribute('data-thread-id');
314 var lastId = parseInt(stream.getAttribute('data-last-id') || '0', 10);
315 var side = stream.getAttribute('data-side') || 'customer';
316
317 // Scroll to bottom on initial render -- newest at bottom.
318 stream.scrollTop = stream.scrollHeight;
319
320 // ---- Compose form -- POST via fetch so we don't reload the page ----
321 var compose = document.getElementById('sc-compose');
322 var body = document.getElementById('sc-body');
323 function sendMessage() {
324 var text = body.value.trim();
325 if (!text) return;
326 // Capture FormData BEFORE disabling the textarea -- FormData()
327 // skips disabled fields, which silently drops the body and the
328 // server returns "empty message". Do NOT re-append body either,
329 // or CGI->Vars joins duplicates with \0 producing "test\0test".
330 var data = new FormData(compose);
331 data.append('thread_id', threadId);
332 data.append('async', '1');
333 body.disabled = true;
334 // Snapshot the attached thumbs so we can optimistically append
335 // them after send -- pollNow occasionally misses (server-side
336 // crash on the poll endpoint) and we don't want the sender to
337 // think nothing happened.
338 var strip = compose.querySelector('[data-att-strip]');
339 var attUrls = [];
340 if (strip) {
341 strip.querySelectorAll('.sup-att-thumb img').forEach(function(im){ attUrls.push(im.src); });
342 }
343 var sentText = text;
344 fetch('/support_send.cgi', { method:'POST', body:data })
345 .then(function(r){ return r.json(); })
346 .then(function(j){
347 body.disabled = false;
348 if (!j || !j.ok) { appToast({ message: 'Send failed: ' + (j && j.error ? j.error : 'unknown'), type: 'error' }); return; }
349 body.value = '';
350 if (strip) strip.innerHTML = '';
351 body.focus();
352 // Optimistic render: drop the message into the stream right
353 // away with the same body+attachment HTML the server will
354 // emit on its next poll. lastId is bumped so the next poll
355 // doesn't re-add it.
356 if (j.message_id) {
357 lastId = Math.max(lastId, parseInt(j.message_id, 10) || 0);
358 var attHtml = '';
359 if (attUrls.length) {
360 attHtml = '<div class="sup-msg-atts">' + attUrls.map(function(u){
361 return '<a href="' + u + '" target="_blank" rel="noopener" class="sup-msg-att"><img src="' + u + '" alt=""></a>';
362 }).join('') + '</div>';
363 }
364 var safe = sentText.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
365 appendMessage({
366 id: j.message_id, sender_role: 'customer', sender_name: 'You',
367 body_html: safe + attHtml, ts: 'just now'
368 });
369 }
370 pollNow();
371 })
372 .catch(function(err){
373 body.disabled = false;
374 appToast({ message: 'Send failed: ' + err.message, type: 'error' });
375 });
376 }
377 if (compose) {
378 compose.addEventListener('submit', function(e){ e.preventDefault(); sendMessage(); });
379 body.addEventListener('keydown', function(e){
380 if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
381 });
382 }
383
384 // ---- Polling for new messages every 12s ----
385 // Async messaging only. Each poll asks the server for messages
386 // newer than the highest id we've seen and appends them inline.
387 function appendMessage(m) {
388 var wrap = document.createElement('div');
389 var roleClass = m.sender_role === 'customer' ? 'is-customer'
390 : m.sender_role === 'admin' ? 'is-admin'
391 : 'is-system';
392 wrap.className = 'sc-msg ' + roleClass;
393 wrap.setAttribute('data-msg-id', m.id);
394 var initial = (m.sender_name || '?').charAt(0).toUpperCase();
395 var avatar = '<div class="sc-avatar">' + (m.sender_role === 'system' ? '&#9432;' : initial) + '</div>';
396 wrap.innerHTML = avatar +
397 '<div>' +
398 ' <div class="sc-bubble">' + m.body_html + '</div>' +
399 ' <div class="sc-meta-line">' + (m.sender_name || '') + ' &middot; just now</div>' +
400 '</div>';
401 stream.appendChild(wrap);
402 stream.scrollTop = stream.scrollHeight;
403 }
404 function pollNow() {
405 fetch('/support_poll.cgi?thread_id=' + encodeURIComponent(threadId) + '&since=' + lastId, { cache:'no-store' })
406 .then(function(r){ return r.json(); })
407 .then(function(j){
408 if (!j || !j.ok) return;
409 (j.messages || []).forEach(function(m){
410 if (m.id > lastId) { lastId = m.id; appendMessage(m); }
411 });
412 })
413 .catch(function(){ /* swallow -- next tick will retry */ });
414 }
415 setInterval(pollNow, 12000);
416})();
417</script>
418
419<!-- ===== Attachment widget JS (shared by modal + reply form) =====
420 Enhances any <form class="sup-att-form"> that has a [data-att-area]
421 child: file picker, drag-and-drop on the whole form, paste-into-
422 textarea, thumbnail strip, async upload to /support_upload.cgi,
423 hidden attach_id inputs the form submits. ============================= -->
424<script>
425(function(){
426 function setupAttachments(form) {
427 var area = form.querySelector('[data-att-area]');
428 if (!area) return;
429 var input = area.querySelector('[data-att-input]');
430 var pick = area.querySelector('[data-att-pick]');
431 var strip = area.querySelector('[data-att-strip]');
432 var ta = form.querySelector('textarea');
433
434 // Inject a live counter chip right after the picker label so the
435 // user gets immediate textual confirmation that the file was picked
436 // (the thumbnail may need a beat to render, especially on mobile).
437 var counter = document.createElement('span');
438 counter.className = 'sup-att-count';
439 counter.setAttribute('data-att-counter', '');
440 counter.style.display = 'none';
441 if (pick && pick.parentNode) pick.parentNode.insertBefore(counter, pick.nextSibling);
442 function updateCounter() {
443 var n = strip.querySelectorAll('.sup-att-thumb').length;
444 var ok = strip.querySelectorAll('input[name="attach_id"]').length;
445 var pending = n - ok;
446 if (n === 0) { counter.style.display = 'none'; return; }
447 counter.style.display = 'inline-flex';
448 if (pending > 0) {
449 counter.textContent = n + ' selected (' + ok + ' ready, ' + pending + ' uploading...)';
450 counter.className = 'sup-att-count is-working';
451 } else {
452 counter.textContent = n + (n === 1 ? ' image attached' : ' images attached');
453 counter.className = 'sup-att-count is-ready';
454 }
455 }
456
457 function makeThumb(src, id, pending, name) {
458 var t = document.createElement('div');
459 t.className = 'sup-att-thumb' + (pending ? ' is-pending' : '');
460 if (name) t.title = name;
461 var img = document.createElement('img');
462 img.src = src;
463 t.appendChild(img);
464 if (!pending && id) {
465 var hidden = document.createElement('input');
466 hidden.type = 'hidden';
467 hidden.name = 'attach_id';
468 hidden.value = id;
469 t.appendChild(hidden);
470 var rm = document.createElement('button');
471 rm.type = 'button';
472 rm.className = 'sup-att-rm';
473 rm.innerHTML = '&times;';
474 rm.setAttribute('aria-label', 'Remove attachment');
475 rm.addEventListener('click', function(){ t.remove(); updateCounter(); });
476 t.appendChild(rm);
477 }
478 return t;
479 }
480
481 function upload(file) {
482 if (!file) return;
483 var t = (file.type || '').toLowerCase();
484 var nm = (file.name || '').toLowerCase();
485 var okType = t.indexOf('image/') === 0;
486 var okExt = false;
487 var exts = ['.jpg','.jpeg','.png','.gif','.webp','.bmp'];
488 for (var ei = 0; ei < exts.length; ei++) {
489 if (nm.length >= exts[ei].length && nm.lastIndexOf(exts[ei]) === nm.length - exts[ei].length) { okExt = true; break; }
490 }
491 if (!okType && !okExt) {
492 appToast({ message: file.name + ': not an image -- only JPG / PNG / GIF / WebP / BMP are accepted.', type: 'warning' });
493 return;
494 }
495 var placeholder = makeThumb(URL.createObjectURL(file), null, true, file.name);
496 strip.appendChild(placeholder);
497 updateCounter();
498 var fd = new FormData();
499 fd.append('image', file);
500 fetch('/support_upload.cgi', { method: 'POST', body: fd, credentials: 'same-origin' })
501 .then(function(r){ return r.json(); })
502 .then(function(j){
503 if (j && j.success) {
504 var real = makeThumb(j.url, j.id, false, j.filename);
505 placeholder.replaceWith(real);
506 } else {
507 placeholder.classList.add('failed');
508 placeholder.classList.remove('is-pending');
509 placeholder.title = (j && j.error) ? j.error : 'upload failed';
510 }
511 updateCounter();
512 })
513 .catch(function(){
514 placeholder.classList.add('failed');
515 placeholder.classList.remove('is-pending');
516 updateCounter();
517 });
518 }
519
520 // The file picker is opened natively by the <label> wrapping the
521 // <input type="file"> -- no JS .click() needed (which is the most
522 // reliable cross-browser path, especially on iOS Safari). We only
523 // need to listen for the change event to upload the chosen files.
524 if (input) {
525 input.addEventListener('change', function(){
526 Array.prototype.slice.call(input.files).forEach(upload);
527 input.value = '';
528 });
529 }
530
531 // Drag/drop on the whole form so the drop zone is huge and
532 // forgiving -- users tend to drop slightly off the strip.
533 ['dragenter','dragover'].forEach(function(ev){
534 form.addEventListener(ev, function(e){
535 if (e.dataTransfer && Array.prototype.indexOf.call(e.dataTransfer.types, 'Files') !== -1) {
536 e.preventDefault();
537 form.classList.add('att-drag');
538 }
539 });
540 });
541 ['dragleave','dragend','drop'].forEach(function(ev){
542 form.addEventListener(ev, function(){ form.classList.remove('att-drag'); });
543 });
544 form.addEventListener('drop', function(e){
545 if (!e.dataTransfer || !e.dataTransfer.files || !e.dataTransfer.files.length) return;
546 e.preventDefault();
547 Array.prototype.slice.call(e.dataTransfer.files).forEach(upload);
548 });
549
550 // Paste an image directly into the textarea (screenshot Ctrl+V).
551 if (ta) {
552 ta.addEventListener('paste', function(e){
553 var items = e.clipboardData && e.clipboardData.items;
554 if (!items) return;
555 for (var i = 0; i < items.length; i++) {
556 if (items[i].kind === 'file') {
557 var f = items[i].getAsFile();
558 if (f) upload(f);
559 }
560 }
561 });
562 }
563 }
564 document.querySelectorAll('form.sup-att-form').forEach(setupAttachments);
565
566 // Global drag-defaults killer. Without these, dropping a file
567 // ANYWHERE outside an explicit drop zone causes the browser to
568 // navigate to that file (image opens in a new tab). We intercept
569 // dragover + drop at the window level, and if the drop didn't land
570 // on a sup-att-form we route the files to the first visible one --
571 // so users get the attachment behavior no matter where they drop.
572 window.addEventListener('dragover', function(e){
573 if (e.dataTransfer && Array.prototype.indexOf.call(e.dataTransfer.types, 'Files') !== -1) {
574 e.preventDefault();
575 }
576 });
577 window.addEventListener('drop', function(e){
578 if (!e.dataTransfer || !e.dataTransfer.files || !e.dataTransfer.files.length) return;
579 var inForm = e.target && e.target.closest && e.target.closest('form.sup-att-form');
580 if (inForm) return; // form's own handler will pick it up
581 e.preventDefault(); // we're catching a drop that would otherwise open in a new tab
582 var firstForm = document.querySelector('form.sup-att-form');
583 if (!firstForm) return;
584 var area = firstForm.querySelector('[data-att-area]');
585 if (!area || area.offsetParent === null) return; // form hidden -- bail
586 // Re-dispatch by directly invoking the form's drop pipeline.
587 var dropEvt = new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: e.dataTransfer });
588 firstForm.dispatchEvent(dropEvt);
589 });
590})();
591</script>