Diff -- /var/www/vhosts/3dshawn.com/crm.3dshawn.com/TEMPLATES/cf_support.html

O Operator
Diff

/var/www/vhosts/3dshawn.com/crm.3dshawn.com/TEMPLATES/cf_support.html

added on local at 2026-07-11 18:32:02

Added
+0
lines
Removed
-0
lines
Context
590
unchanged
Blobs
from ac0a8124b126
to ac0a8124b126
Restore this content →
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.
11<!-- =====================================================================
22 CUSTOMER SUPPORT CENTER
33 /support.cgi -- contact's view of their support threads with ContactForge.
44
55 Two-pane layout:
66 Left -- scrollable thread list with unread badges
77 Right -- active conversation + reply form (or empty state)
88
99 Async messaging only. Live-chat UI was removed; the conversation
1010 polls for new admin replies every 12s.
1111
1212 Theme-aware via var(--col-*) so this works under every dashboard
1313 color theme + user-side wrapper.
1414===================================================================== -->
1515<style>
1616 .sc-page { display:grid;grid-template-columns:340px 1fr;gap:20px;height:calc(100vh - 100px);min-height:560px;padding:0;margin:0 -8px; }
1717 .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; }
1818
1919 /* Left pane: thread list */
2020 .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; }
2121 .sc-list-title { font-family:var(--font-display);font-size:18px;color:var(--col-text);margin:0; }
2222 .sc-list-sub { font-size:11px;color:var(--col-text-3);letter-spacing:1px;text-transform:uppercase; }
2323 .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; }
2424 .sc-new-btn:hover { filter:brightness(1.08); }
2525 .sc-list { flex:1;overflow-y:auto;padding:8px; }
2626 .sc-list-empty { padding:50px 22px;text-align:center;color:var(--col-text-3);font-size:13px;line-height:1.6; }
2727 /* Compact one-line thread rows -- everything on a single horizontal
2828 flex line so the inbox fits 3-4x more threads per viewport. Same
2929 anatomy as the admin support inbox so the two surfaces feel like
3030 mirrors of each other. */
3131 .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; }
3232 .sc-thread:hover { background:var(--col-surface-2);border-color:var(--col-border); }
3333 .sc-thread.is-active { background:var(--col-accent-glow);border-color:var(--col-accent); }
3434 .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; }
3535 .sc-thread-time { font-size:11px;color:var(--col-text-3);flex-shrink:0; }
3636 .sc-pill { padding:1px 7px;border-radius:10px;font-size:10px;font-weight:700;letter-spacing:0.4px;flex-shrink:0; }
3737 .sc-pill.st-open { background:rgba(59,130,246,0.18);color:#60a5fa;border:1px solid rgba(59,130,246,0.36); }
3838 .sc-pill.st-waiting { background:rgba(245,158,11,0.15);color:#fbbf24;border:1px solid rgba(245,158,11,0.32); }
3939 .sc-pill.st-resolved { background:rgba(34,197,94,0.15);color:#4ade80;border:1px solid rgba(34,197,94,0.32); }
4040 .sc-pill.st-closed { background:rgba(148,163,184,0.15);color:#cbd5e1;border:1px solid rgba(148,163,184,0.32); }
4141 .sc-pill.chat { background:rgba(124,58,237,0.18);color:#c4b5fd;border:1px solid rgba(124,58,237,0.36); }
4242 .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; }
4343
4444 /* Right pane: active conversation */
4545 .sc-conv { display:flex;flex-direction:column;flex:1;min-height:0; }
4646 .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; }
4747 .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; }
4848 .sc-conv-title span { overflow:hidden;text-overflow:ellipsis;white-space:nowrap; }
4949 .sc-conv-actions { display:flex;gap:8px;flex-shrink:0; }
5050 .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; }
5151 .sc-btn:hover { background:var(--col-accent-glow);border-color:var(--col-accent); }
5252 .sc-btn.is-danger { background:rgba(239,68,68,0.12);border-color:rgba(239,68,68,0.4);color:#f87171; }
5353 .sc-btn.is-danger:hover { background:rgba(239,68,68,0.22); }
5454 .sc-btn.is-primary { background:linear-gradient(135deg,var(--col-accent),var(--col-accent-deep));border:none;color:#fff; }
5555 .sc-btn.is-primary:hover { filter:brightness(1.08); }
5656
5757 .sc-stream { flex:1;overflow-y:auto;padding:18px 22px;display:flex;flex-direction:column;gap:14px; }
5858
5959 .sc-msg { display:flex;gap:10px;max-width:78%; }
6060 .sc-msg.is-customer { margin-left:auto;flex-direction:row-reverse; }
6161 .sc-msg.is-system { margin:0 auto;max-width:90%; }
6262 .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; }
6363 .sc-msg.is-customer .sc-avatar { background:linear-gradient(135deg,#10b981,#059669); }
6464 .sc-msg.is-system .sc-avatar { background:var(--col-surface-2);color:var(--col-text-3);border:1px solid var(--col-border); }
6565 .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); }
6666 .sc-msg.is-customer .sc-bubble { background:var(--col-accent-glow);border-color:var(--col-accent); }
6767 .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; }
6868 .sc-meta-line { font-size:10.5px;color:var(--col-text-3);margin-top:4px;letter-spacing:0.3px; }
6969 .sc-msg.is-customer .sc-meta-line { text-align:right; }
7070
7171 .sc-compose { border-top:1px solid var(--col-border);padding:14px 18px;flex-shrink:0;background:var(--col-surface-2); }
7272 .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; }
7373 .sc-compose textarea:focus { outline:none;border-color:var(--col-accent); }
7474 .sc-compose-row { display:flex;justify-content:space-between;align-items:center;margin-top:8px;gap:10px; }
7575 .sc-compose-hint { font-size:11px;color:var(--col-text-3); }
7676
7777 .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; }
7878 .sc-invite-text { font-size:13px;color:var(--col-text); }
7979 .sc-invite-text strong { color:#c4b5fd; }
8080
8181 .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; }
8282 .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; }
8383 @keyframes scPulse { 0%, 100% { opacity:1 } 50% { opacity:0.35 } }
8484
8585 .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; }
8686 .sc-empty h2 { font-family:var(--font-display);font-size:22px;color:var(--col-text);margin:0; }
8787 .sc-empty p { margin:0;max-width:380px;line-height:1.6;font-size:13.5px; }
8888
8989 /* New-request modal */
9090 .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; }
9191 .sc-modal.is-open { display:flex; }
9292 .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; }
9393 .sc-modal-title { font-family:var(--font-display);font-size:26px;color:var(--col-text);margin:0 0 8px; }
9494 .sc-modal-sub { font-size:12px;letter-spacing:1.5px;text-transform:uppercase;color:var(--col-text-3);margin-bottom:14px; }
9595 .sc-modal-hint { font-size:13.5px;color:var(--col-text-2);line-height:1.6;margin:0 0 18px;max-width:680px; }
9696 .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; }
9797 .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; }
9898 .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); }
9999 .sc-modal textarea { min-height:360px;resize:vertical; }
100100 .sc-modal-actions { display:flex;gap:10px;margin-top:22px;justify-content:flex-end; }
101101
102102 /* Attachment widget: drag/drop + paste + file picker, shared by the
103103 new-thread modal and the reply form so customers and admins have
104104 the same upload experience everywhere. */
105105 .sup-att-area { margin-top: 12px; }
106106 .sup-att-strip { display:flex;flex-wrap:wrap;gap:8px;margin-bottom:10px; }
107107 .sup-att-strip:empty { display:none; }
108108 .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); }
109109 .sup-att-thumb img { width:100%;height:100%;object-fit:cover;display:block; }
110110 .sup-att-thumb.is-pending { opacity:0.55; }
111111 .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; }
112112 .sup-att-thumb.failed { border-color:#ef4444;opacity:0.7; }
113113 .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); }
114114 @keyframes sup-att-shimmer { 0% { transform:translateX(-100%); } 100% { transform:translateX(100%); } }
115115 .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; }
116116 .sup-att-rm:hover { background:#ef4444; }
117117 .sup-att-controls { display:flex;align-items:center;gap:10px;flex-wrap:wrap; }
118118 .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; }
119119 .sup-att-pick:hover { background:rgba(59,130,246,0.18);color:#fff;border-color:rgba(59,130,246,0.55); }
120120 .sup-att-pick svg { width:14px;height:14px; }
121121 /* Visually hide the file input but keep it focusable + clickable via
122122 its parent <label>. display:none would block the file picker on
123123 some mobile browsers; this positions it out of flow instead. */
124124 .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; }
125125 .sup-att-hint { font-size:11px;color:var(--col-text-3); }
126126 .sup-att-form.att-drag { outline:2px dashed var(--col-accent);outline-offset:4px;border-radius:12px; }
127127 /* Live counter chip that appears the moment files are picked, so
128128 the user gets immediate confirmation even before thumbs render. */
129129 .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; }
130130 .sup-att-count.is-working { background:rgba(245,158,11,0.18);border:1px solid rgba(245,158,11,0.45);color:#fde68a; }
131131 .sup-att-count.is-ready { background:rgba(16,185,129,0.18);border:1px solid rgba(16,185,129,0.55);color:#6ee7b7; }
132132 .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; }
133133 .sup-att-count.is-ready::before { content:'\2713';font-weight:800;color:#10b981; }
134134 @keyframes sup-att-blink { 0%,100% { opacity:1; } 50% { opacity:0.35; } }
135135
136136 /* Inline-rendered attachments inside a message bubble. */
137137 .sup-msg-atts { display:flex;flex-wrap:wrap;gap:6px;margin-top:8px; }
138138 .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; }
139139 .sup-msg-att:hover { border-color:var(--col-accent);transform:translateY(-1px); }
140140 .sup-msg-att img { display:block;max-width:280px;max-height:200px;width:auto;height:auto; }
141141 @media (max-width: 720px) {
142142 .sc-modal-card { padding:22px 18px; }
143143 .sc-modal textarea { min-height:240px; }
144144 }
145145
146146 @media (max-width:1000px) {
147147 .sc-page { grid-template-columns:1fr;height:auto; }
148148 .sc-pane.sc-left { max-height:340px; }
149149 }
150150</style>
151151
152152
153153<div class="page-head">
154154 <div>
155155 <span class="page-eyebrow"><span class="dot"></span> Support</span>
156156 <h1 class="page-title">Help &amp; messages</h1>
157157 <p class="page-subtitle">Send a question to the ContactForge team and we&#39;ll reply right here. We aim to respond within one business day.</p>
158158 </div>
159159</div>
160160
161161[if:$just_opened]
162162<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">
163163 <strong>Request submitted.</strong> A team member will reply here as soon as we can.
164164</div>
165165[/if]
166166
167167<div class="sc-page">
168168
169169 <!-- LEFT: thread list -->
170170 <div class="sc-pane sc-left">
171171 <div class="sc-list-head">
172172 <div>
173173 <div class="sc-list-sub">Your threads</div>
174174 <h2 class="sc-list-title">$thread_count active</h2>
175175 </div>
176176 <button type="button" class="sc-new-btn" data-sc-open="sc-new">+ New</button>
177177 </div>
178178 <div class="sc-list">
179179 [if:!$has_threads]
180180 <div class="sc-list-empty">
181181 No conversations yet.<br><br>
182182 <button type="button" class="sc-new-btn" data-sc-open="sc-new">Start your first support request</button>
183183 </div>
184184 [/if]
185185 [if:$has_threads]
186186 [loop:@threads]
187187 <a class="sc-thread[if:$loop1.is_active] is-active[/if]" href="/support.cgi?thread_id=$loop1.id" title="$loop1.subject -- $loop1.status_label">
188188 <span class="sc-thread-subj">$loop1.subject</span>
189189 <span class="sc-pill $loop1.status_class">$loop1.status_label</span>
190190 [if:$loop1.has_unread]<span class="sc-unread">$loop1.unread</span>[/if]
191191 <span class="sc-thread-time"><span class="ts" data-ts="$loop1.last_message_epoch" data-fmt="relative">$loop1.last_at</span></span>
192192 </a>
193193 [/loop]
194194 [/if]
195195 </div>
196196 </div>
197197
198198 <!-- RIGHT: active conversation -->
199199 <div class="sc-pane sc-right">
200200 [if:!$has_active]
201201 <div class="sc-empty">
202202 <div style="font-size:48px;opacity:0.5">&#9993;</div>
203203 <h2>No conversation selected</h2>
204204 <p>Pick a thread from the left to read replies, or start a new support request and the ContactForge team will get back to you.</p>
205205 <button type="button" class="sc-new-btn" data-sc-open="sc-new">+ Start a new request</button>
206206 </div>
207207 [/if]
208208
209209 [if:$has_active]
210210 <div class="sc-conv">
211211 <div class="sc-conv-head">
212212 <div class="sc-conv-title">
213213 <span class="sc-pill $active_status_class" style="font-size:10px">$active_status</span>
214214 <span>$active_subject</span>
215215 </div>
216216 </div>
217217
218218 <div class="sc-stream" id="sc-stream" data-thread-id="$active_id" data-last-id="$last_message_id" data-side="customer">
219219 [loop:@messages]
220220 <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">
221221 [if:!$loop1.is_system]<div class="sc-avatar">$loop1.sender_initial</div>[/if]
222222 [if:$loop1.is_system]<div class="sc-avatar">&#9432;</div>[/if]
223223 <div>
224224 <div class="sc-bubble">$loop1.body_html</div>
225225 [if:$loop1.has_attach]
226226 <div class="sup-msg-atts">
227227 [loop:@attachments]
228228 <a href="$loop2.url" target="_blank" rel="noopener" class="sup-msg-att" title="$loop2.name"><img src="$loop2.url" alt=""></a>
229229 [/loop]
230230 </div>
231231 [/if]
232232 <div class="sc-meta-line">$loop1.sender_name &middot; <span class="ts" data-ts="$loop1.msg_epoch" data-fmt="datetime">$loop1.ts</span></div>
233233 </div>
234234 </div>
235235 [/loop]
236236 </div>
237237
238238 <form class="sc-compose sup-att-form" id="sc-compose" data-thread-id="$active_id">
239239 <textarea name="body" id="sc-body" placeholder="Write a message..." required></textarea>
240240 <div class="sup-att-area" data-att-area>
241241 <div class="sup-att-strip" data-att-strip></div>
242242 <div class="sup-att-controls">
243243 <label class="sup-att-pick" data-att-pick>
244244 <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>
245245 <span>Attach image</span>
246246 <input type="file" data-att-input accept="image/jpeg,image/png,image/gif,image/webp,image/bmp,.jpg,.jpeg,.png,.gif,.webp,.bmp" multiple>
247247 </label>
248248 <span class="sup-att-hint">Drag &amp; drop or paste also works</span>
249249 </div>
250250 </div>
251251 <div class="sc-compose-row">
252252 <span class="sc-compose-hint">Enter to send &middot; Shift+Enter for a new line</span>
253253 <button type="submit" class="sc-btn is-primary">Send</button>
254254 </div>
255255 </form>
256256 </div>
257257 [/if]
258258 </div>
259259</div>
260260
261261
262262<!-- NEW REQUEST MODAL -->
263263<div class="sc-modal[if:$show_new_form] is-open[/if]" id="sc-new" role="dialog" aria-modal="true">
264264 <div class="sc-modal-card">
265265 <div class="sc-modal-sub">New support request</div>
266266 <h2 class="sc-modal-title">Tell us what&#39;s happening</h2>
267267 <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>
268268 [if:$has_new_err]
269269 <div class="banner warning" style="margin-bottom:12px">Subject and message are both required.</div>
270270 [/if]
271271 <form method="post" action="/support.cgi" autocomplete="off" class="sup-att-form">
272272 <input type="hidden" name="action" value="create_thread">
273273 <label for="sc-subj">Subject</label>
274274 <input id="sc-subj" type="text" name="subject" required maxlength="200" placeholder="Brief summary of the issue or question">
275275 <label for="sc-body-new">Message</label>
276276 <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>
277277 <div class="sup-att-area" data-att-area>
278278 <label style="display:block;margin-top:14px">Attachments</label>
279279 <div class="sup-att-strip" data-att-strip></div>
280280 <div class="sup-att-controls">
281281 <label class="sup-att-pick" data-att-pick>
282282 <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>
283283 <span>Attach images</span>
284284 <input type="file" data-att-input accept="image/jpeg,image/png,image/gif,image/webp,image/bmp,.jpg,.jpeg,.png,.gif,.webp,.bmp" multiple>
285285 </label>
286286 <span class="sup-att-hint">Or drag &amp; drop, or paste (JPG / PNG / GIF / WebP / BMP &middot; 10MB max)</span>
287287 </div>
288288 </div>
289289 <div class="sc-modal-actions">
290290 <button type="button" class="sc-btn" data-sc-close>Cancel</button>
291291 <button type="submit" class="sc-btn is-primary">Send request</button>
292292 </div>
293293 </form>
294294 </div>
295295</div>
296296
297297
298298<script>
299299(function(){
300300 // Modal open/close
301301 document.addEventListener('click', function(e){
302302 var open = e.target.closest('[data-sc-open]');
303303 if (open) { var m = document.getElementById(open.getAttribute('data-sc-open')); if (m) m.classList.add('is-open'); e.preventDefault(); return; }
304304 var close = e.target.closest('[data-sc-close]');
305305 if (close) { var m2 = close.closest('.sc-modal'); if (m2) m2.classList.remove('is-open'); return; }
306306 if (e.target.classList && e.target.classList.contains('sc-modal')) { e.target.classList.remove('is-open'); }
307307 });
308308
309309 var stream = document.getElementById('sc-stream');
310310 if (!stream) return; // no active thread
311311
312312 var threadId = stream.getAttribute('data-thread-id');
313313 var lastId = parseInt(stream.getAttribute('data-last-id') || '0', 10);
314314 var side = stream.getAttribute('data-side') || 'customer';
315315
316316 // Scroll to bottom on initial render -- newest at bottom.
317317 stream.scrollTop = stream.scrollHeight;
318318
319319 // ---- Compose form -- POST via fetch so we don't reload the page ----
320320 var compose = document.getElementById('sc-compose');
321321 var body = document.getElementById('sc-body');
322322 function sendMessage() {
323323 var text = body.value.trim();
324324 if (!text) return;
325325 // Capture FormData BEFORE disabling the textarea -- FormData()
326326 // skips disabled fields, which silently drops the body and the
327327 // server returns "empty message". Do NOT re-append body either,
328328 // or CGI->Vars joins duplicates with \0 producing "test\0test".
329329 var data = new FormData(compose);
330330 data.append('thread_id', threadId);
331331 data.append('async', '1');
332332 body.disabled = true;
333333 // Snapshot the attached thumbs so we can optimistically append
334334 // them after send -- pollNow occasionally misses (server-side
335335 // crash on the poll endpoint) and we don't want the sender to
336336 // think nothing happened.
337337 var strip = compose.querySelector('[data-att-strip]');
338338 var attUrls = [];
339339 if (strip) {
340340 strip.querySelectorAll('.sup-att-thumb img').forEach(function(im){ attUrls.push(im.src); });
341341 }
342342 var sentText = text;
343343 fetch('/support_send.cgi', { method:'POST', body:data })
344344 .then(function(r){ return r.json(); })
345345 .then(function(j){
346346 body.disabled = false;
347347 if (!j || !j.ok) { appToast({ message: 'Send failed: ' + (j && j.error ? j.error : 'unknown'), type: 'error' }); return; }
348348 body.value = '';
349349 if (strip) strip.innerHTML = '';
350350 body.focus();
351351 // Optimistic render: drop the message into the stream right
352352 // away with the same body+attachment HTML the server will
353353 // emit on its next poll. lastId is bumped so the next poll
354354 // doesn't re-add it.
355355 if (j.message_id) {
356356 lastId = Math.max(lastId, parseInt(j.message_id, 10) || 0);
357357 var attHtml = '';
358358 if (attUrls.length) {
359359 attHtml = '<div class="sup-msg-atts">' + attUrls.map(function(u){
360360 return '<a href="' + u + '" target="_blank" rel="noopener" class="sup-msg-att"><img src="' + u + '" alt=""></a>';
361361 }).join('') + '</div>';
362362 }
363363 var safe = sentText.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
364364 appendMessage({
365365 id: j.message_id, sender_role: 'customer', sender_name: 'You',
366366 body_html: safe + attHtml, ts: 'just now'
367367 });
368368 }
369369 pollNow();
370370 })
371371 .catch(function(err){
372372 body.disabled = false;
373373 appToast({ message: 'Send failed: ' + err.message, type: 'error' });
374374 });
375375 }
376376 if (compose) {
377377 compose.addEventListener('submit', function(e){ e.preventDefault(); sendMessage(); });
378378 body.addEventListener('keydown', function(e){
379379 if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
380380 });
381381 }
382382
383383 // ---- Polling for new messages every 12s ----
384384 // Async messaging only. Each poll asks the server for messages
385385 // newer than the highest id we've seen and appends them inline.
386386 function appendMessage(m) {
387387 var wrap = document.createElement('div');
388388 var roleClass = m.sender_role === 'customer' ? 'is-customer'
389389 : m.sender_role === 'admin' ? 'is-admin'
390390 : 'is-system';
391391 wrap.className = 'sc-msg ' + roleClass;
392392 wrap.setAttribute('data-msg-id', m.id);
393393 var initial = (m.sender_name || '?').charAt(0).toUpperCase();
394394 var avatar = '<div class="sc-avatar">' + (m.sender_role === 'system' ? '&#9432;' : initial) + '</div>';
395395 wrap.innerHTML = avatar +
396396 '<div>' +
397397 ' <div class="sc-bubble">' + m.body_html + '</div>' +
398398 ' <div class="sc-meta-line">' + (m.sender_name || '') + ' &middot; just now</div>' +
399399 '</div>';
400400 stream.appendChild(wrap);
401401 stream.scrollTop = stream.scrollHeight;
402402 }
403403 function pollNow() {
404404 fetch('/support_poll.cgi?thread_id=' + encodeURIComponent(threadId) + '&since=' + lastId, { cache:'no-store' })
405405 .then(function(r){ return r.json(); })
406406 .then(function(j){
407407 if (!j || !j.ok) return;
408408 (j.messages || []).forEach(function(m){
409409 if (m.id > lastId) { lastId = m.id; appendMessage(m); }
410410 });
411411 })
412412 .catch(function(){ /* swallow -- next tick will retry */ });
413413 }
414414 setInterval(pollNow, 12000);
415415})();
416416</script>
417417
418418<!-- ===== Attachment widget JS (shared by modal + reply form) =====
419419 Enhances any <form class="sup-att-form"> that has a [data-att-area]
420420 child: file picker, drag-and-drop on the whole form, paste-into-
421421 textarea, thumbnail strip, async upload to /support_upload.cgi,
422422 hidden attach_id inputs the form submits. ============================= -->
423423<script>
424424(function(){
425425 function setupAttachments(form) {
426426 var area = form.querySelector('[data-att-area]');
427427 if (!area) return;
428428 var input = area.querySelector('[data-att-input]');
429429 var pick = area.querySelector('[data-att-pick]');
430430 var strip = area.querySelector('[data-att-strip]');
431431 var ta = form.querySelector('textarea');
432432
433433 // Inject a live counter chip right after the picker label so the
434434 // user gets immediate textual confirmation that the file was picked
435435 // (the thumbnail may need a beat to render, especially on mobile).
436436 var counter = document.createElement('span');
437437 counter.className = 'sup-att-count';
438438 counter.setAttribute('data-att-counter', '');
439439 counter.style.display = 'none';
440440 if (pick && pick.parentNode) pick.parentNode.insertBefore(counter, pick.nextSibling);
441441 function updateCounter() {
442442 var n = strip.querySelectorAll('.sup-att-thumb').length;
443443 var ok = strip.querySelectorAll('input[name="attach_id"]').length;
444444 var pending = n - ok;
445445 if (n === 0) { counter.style.display = 'none'; return; }
446446 counter.style.display = 'inline-flex';
447447 if (pending > 0) {
448448 counter.textContent = n + ' selected (' + ok + ' ready, ' + pending + ' uploading...)';
449449 counter.className = 'sup-att-count is-working';
450450 } else {
451451 counter.textContent = n + (n === 1 ? ' image attached' : ' images attached');
452452 counter.className = 'sup-att-count is-ready';
453453 }
454454 }
455455
456456 function makeThumb(src, id, pending, name) {
457457 var t = document.createElement('div');
458458 t.className = 'sup-att-thumb' + (pending ? ' is-pending' : '');
459459 if (name) t.title = name;
460460 var img = document.createElement('img');
461461 img.src = src;
462462 t.appendChild(img);
463463 if (!pending && id) {
464464 var hidden = document.createElement('input');
465465 hidden.type = 'hidden';
466466 hidden.name = 'attach_id';
467467 hidden.value = id;
468468 t.appendChild(hidden);
469469 var rm = document.createElement('button');
470470 rm.type = 'button';
471471 rm.className = 'sup-att-rm';
472472 rm.innerHTML = '&times;';
473473 rm.setAttribute('aria-label', 'Remove attachment');
474474 rm.addEventListener('click', function(){ t.remove(); updateCounter(); });
475475 t.appendChild(rm);
476476 }
477477 return t;
478478 }
479479
480480 function upload(file) {
481481 if (!file) return;
482482 var t = (file.type || '').toLowerCase();
483483 var nm = (file.name || '').toLowerCase();
484484 var okType = t.indexOf('image/') === 0;
485485 var okExt = false;
486486 var exts = ['.jpg','.jpeg','.png','.gif','.webp','.bmp'];
487487 for (var ei = 0; ei < exts.length; ei++) {
488488 if (nm.length >= exts[ei].length && nm.lastIndexOf(exts[ei]) === nm.length - exts[ei].length) { okExt = true; break; }
489489 }
490490 if (!okType && !okExt) {
491491 appToast({ message: file.name + ': not an image -- only JPG / PNG / GIF / WebP / BMP are accepted.', type: 'warning' });
492492 return;
493493 }
494494 var placeholder = makeThumb(URL.createObjectURL(file), null, true, file.name);
495495 strip.appendChild(placeholder);
496496 updateCounter();
497497 var fd = new FormData();
498498 fd.append('image', file);
499499 fetch('/support_upload.cgi', { method: 'POST', body: fd, credentials: 'same-origin' })
500500 .then(function(r){ return r.json(); })
501501 .then(function(j){
502502 if (j && j.success) {
503503 var real = makeThumb(j.url, j.id, false, j.filename);
504504 placeholder.replaceWith(real);
505505 } else {
506506 placeholder.classList.add('failed');
507507 placeholder.classList.remove('is-pending');
508508 placeholder.title = (j && j.error) ? j.error : 'upload failed';
509509 }
510510 updateCounter();
511511 })
512512 .catch(function(){
513513 placeholder.classList.add('failed');
514514 placeholder.classList.remove('is-pending');
515515 updateCounter();
516516 });
517517 }
518518
519519 // The file picker is opened natively by the <label> wrapping the
520520 // <input type="file"> -- no JS .click() needed (which is the most
521521 // reliable cross-browser path, especially on iOS Safari). We only
522522 // need to listen for the change event to upload the chosen files.
523523 if (input) {
524524 input.addEventListener('change', function(){
525525 Array.prototype.slice.call(input.files).forEach(upload);
526526 input.value = '';
527527 });
528528 }
529529
530530 // Drag/drop on the whole form so the drop zone is huge and
531531 // forgiving -- users tend to drop slightly off the strip.
532532 ['dragenter','dragover'].forEach(function(ev){
533533 form.addEventListener(ev, function(e){
534534 if (e.dataTransfer && Array.prototype.indexOf.call(e.dataTransfer.types, 'Files') !== -1) {
535535 e.preventDefault();
536536 form.classList.add('att-drag');
537537 }
538538 });
539539 });
540540 ['dragleave','dragend','drop'].forEach(function(ev){
541541 form.addEventListener(ev, function(){ form.classList.remove('att-drag'); });
542542 });
543543 form.addEventListener('drop', function(e){
544544 if (!e.dataTransfer || !e.dataTransfer.files || !e.dataTransfer.files.length) return;
545545 e.preventDefault();
546546 Array.prototype.slice.call(e.dataTransfer.files).forEach(upload);
547547 });
548548
549549 // Paste an image directly into the textarea (screenshot Ctrl+V).
550550 if (ta) {
551551 ta.addEventListener('paste', function(e){
552552 var items = e.clipboardData && e.clipboardData.items;
553553 if (!items) return;
554554 for (var i = 0; i < items.length; i++) {
555555 if (items[i].kind === 'file') {
556556 var f = items[i].getAsFile();
557557 if (f) upload(f);
558558 }
559559 }
560560 });
561561 }
562562 }
563563 document.querySelectorAll('form.sup-att-form').forEach(setupAttachments);
564564
565565 // Global drag-defaults killer. Without these, dropping a file
566566 // ANYWHERE outside an explicit drop zone causes the browser to
567567 // navigate to that file (image opens in a new tab). We intercept
568568 // dragover + drop at the window level, and if the drop didn't land
569569 // on a sup-att-form we route the files to the first visible one --
570570 // so users get the attachment behavior no matter where they drop.
571571 window.addEventListener('dragover', function(e){
572572 if (e.dataTransfer && Array.prototype.indexOf.call(e.dataTransfer.types, 'Files') !== -1) {
573573 e.preventDefault();
574574 }
575575 });
576576 window.addEventListener('drop', function(e){
577577 if (!e.dataTransfer || !e.dataTransfer.files || !e.dataTransfer.files.length) return;
578578 var inForm = e.target && e.target.closest && e.target.closest('form.sup-att-form');
579579 if (inForm) return; // form's own handler will pick it up
580580 e.preventDefault(); // we're catching a drop that would otherwise open in a new tab
581581 var firstForm = document.querySelector('form.sup-att-form');
582582 if (!firstForm) return;
583583 var area = firstForm.querySelector('[data-att-area]');
584584 if (!area || area.offsetParent === null) return; // form hidden -- bail
585585 // Re-dispatch by directly invoking the form's drop pipeline.
586586 var dropEvt = new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: e.dataTransfer });
587587 firstForm.dispatchEvent(dropEvt);
588588 });
589589})();
590590</script>