Diff -- /var/www/vhosts/3dshawn.com/affiliate.3dshawn.com/TEMPLATES/webstls_support.html

O Operator
Diff

/var/www/vhosts/3dshawn.com/affiliate.3dshawn.com/TEMPLATES/webstls_support.html

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

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