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

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

added on local at 2026-07-01 13:48:13

Added
+964
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to 067bb3d46456
Restore this content →
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.
1<!DOCTYPE html>
2<html lang="en" class="$html_class">
3<head>
4 <meta charset="UTF-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1.0">
6<meta http-equiv="Cache-Control" content="no-store, no-cache, must-revalidate, max-age=0">
7<meta http-equiv="Pragma" content="no-cache">
8<meta http-equiv="Expires" content="0">
9 <title>$title &middot; $website_title</title>
10$meta_tags
11 <link rel="preconnect" href="https://fonts.googleapis.com">
12 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
13 <link href="https://fonts.googleapis.com/css2?family=Inter:wght\@400;500;600;700&family=Space+Grotesk:wght\@500;600;700&family=JetBrains+Mono:wght\@400;600&display=swap" rel="stylesheet">
14 <link rel="stylesheet" href="$assets_css/site.css?v=20260520a">
15 <style>
16 /* Collapsible sidebar. The .app grid swaps its first column from
17 the normal sidebar width to 0; the sidebar itself fades out so
18 its contents don't peek through during the transition. The
19 toggle button always lives in the topbar so it's reachable
20 whether the sidebar is open or closed. */
21 .app { transition: grid-template-columns 0.22s ease; }
22 .sidebar { transition: opacity 0.22s ease, visibility 0.22s; overflow: hidden; }
23 html.sidebar-collapsed .app { grid-template-columns: 0 1fr; }
24 html.sidebar-collapsed .sidebar { opacity: 0; visibility: hidden; }
25 /* When the sidebar is collapsed the content area is full-width --
26 drop the centered max-width cap and reduce the gutter padding so
27 the main content uses the freed real estate instead of leaving
28 a wide margin where the sidebar used to be. */
29 html.sidebar-collapsed .app-content { max-width: none; padding-left: 20px; padding-right: 20px; }
30 html.sidebar-collapsed .topbar { padding-left: 20px; padding-right: 20px; }
31
32 /* Topbar mini brand. Hidden when the sidebar is open (the sidebar
33 carries the logo) and shown when the sidebar is collapsed so the
34 company brand is always on-screen. */
35 .topbar-brand {
36 display: none;
37 align-items: center;
38 gap: 10px;
39 text-decoration: none;
40 color: inherit;
41 margin-right: 14px;
42 }
43 .topbar-brand-name {
44 font-family: var(--font-display, inherit);
45 font-size: 15px;
46 font-weight: 700;
47 color: #fff;
48 letter-spacing: 0.3px;
49 }
50 html.sidebar-collapsed .topbar-brand { display: inline-flex; }
51 /* Edge-tab toggle sits at left:0 when collapsed -- nudge the
52 topbar brand right so the chevron does not overlap it. */
53 html.sidebar-collapsed .topbar { padding-left: 48px; }
54
55 /* Admin chat pill -- always-present topbar control with two
56 states: idle (blue, calm) when the queue is empty, and active
57 (green, pulsing) when visitors are waiting. The DOM is the
58 same in both states; JS just swaps cw-idle/cw-active classes
59 so transitions animate cleanly when a new chat lands or gets
60 resolved. */
61 .chat-pill {
62 position: relative;
63 display: inline-flex;
64 align-items: center;
65 gap: 9px;
66 padding: 0 14px;
67 height: 36px;
68 border-radius: 10px;
69 font-size: 12px;
70 font-weight: 700;
71 letter-spacing: 0.5px;
72 text-transform: uppercase;
73 text-decoration: none;
74 transition: background 0.18s, color 0.18s, transform 0.12s, box-shadow 0.18s, border-color 0.18s;
75 }
76 .chat-pill .cw-count { font-weight: 800; color: #fff; line-height: 1; }
77 .chat-pill .cw-badge {
78 display: inline-flex;
79 align-items: center;
80 color: #fff;
81 font-size: 12px;
82 font-weight: 800;
83 line-height: 1;
84 margin-left: 4px;
85 }
86 .chat-pill .cw-badge.cw-badge-empty { display: none; }
87 .cw-ping {
88 position: relative;
89 width: 9px; height: 9px;
90 border-radius: 50%;
91 flex-shrink: 0;
92 transition: background 0.18s, box-shadow 0.18s;
93 }
94 .cw-ping::before,
95 .cw-ping::after {
96 content: '';
97 position: absolute;
98 inset: 0;
99 border-radius: 50%;
100 opacity: 0;
101 pointer-events: none;
102 }
103 /* --- Idle: blue, no animation -------------------------------- */
104 .chat-pill.cw-idle {
105 background: linear-gradient(135deg, rgba(59,130,246,0.18) 0%, rgba(37,99,235,0.12) 100%);
106 border: 1px solid rgba(59,130,246,0.45);
107 color: #93c5fd;
108 box-shadow: 0 0 10px rgba(37,99,235,0.18);
109 }
110 .chat-pill.cw-idle:hover {
111 background: linear-gradient(135deg, rgba(59,130,246,0.28) 0%, rgba(37,99,235,0.20) 100%);
112 color: #fff;
113 transform: translateY(-1px);
114 box-shadow: 0 0 16px rgba(37,99,235,0.45);
115 }
116 .chat-pill.cw-idle:focus-visible {
117 outline: 2px solid #60a5fa;
118 outline-offset: 2px;
119 }
120 .chat-pill.cw-idle .cw-ping {
121 background: #3b82f6;
122 box-shadow: 0 0 6px rgba(59,130,246,0.7);
123 }
124
125 /* --- Active: green, pulsing dot + count badge ----------------- */
126 .chat-pill.cw-active {
127 background: linear-gradient(135deg, rgba(16,185,129,0.28) 0%, rgba(16,185,129,0.16) 100%);
128 border: 1px solid rgba(16,185,129,0.70);
129 color: #fff;
130 box-shadow: 0 0 18px rgba(16,185,129,0.45);
131 transition: opacity 0.45s ease-in-out, background 0.18s, color 0.18s, transform 0.12s, box-shadow 0.18s, border-color 0.18s;
132 }
133 /* JS toggles this class on/off every second while active, giving
134 a fade-in/fade-out blink that can't be killed by OS-level
135 prefers-reduced-motion (which silences CSS @keyframes). */
136 .chat-pill.cw-active.cw-blink-off { opacity: 0.65; }
137 .chat-pill.cw-active:hover {
138 background: linear-gradient(135deg, rgba(16,185,129,0.34) 0%, rgba(16,185,129,0.20) 100%);
139 color: #fff;
140 transform: translateY(-1px);
141 box-shadow: 0 0 22px rgba(16,185,129,0.65);
142 }
143 .chat-pill.cw-active:focus-visible {
144 outline: 2px solid #34d399;
145 outline-offset: 2px;
146 }
147 .chat-pill.cw-active .cw-ping {
148 background: #10b981;
149 box-shadow: 0 0 8px rgba(16,185,129,0.9);
150 }
151 .chat-pill.cw-active .cw-ping::before,
152 .chat-pill.cw-active .cw-ping::after {
153 background: #10b981;
154 animation: cw-ping 1.8s cubic-bezier(0.0, 0.0, 0.2, 1) infinite;
155 }
156 .chat-pill.cw-active .cw-ping::after { animation-delay: 0.9s; }
157
158 @keyframes cw-ping {
159 0% { transform: scale(1); opacity: 0.7; }
160 80% { opacity: 0; }
161 100% { transform: scale(3.6); opacity: 0; }
162 }
163 /* Whole-box blink: matches what the pill looks like on hover at
164 its peak, then settles back to idle-active. Animates background,
165 border, AND box-shadow so the entire pill feels alive instead
166 of just a soft outer glow change. */
167 /* Slow opacity fade -- this animates THE single most reliable
168 CSS property (opacity has no transition competition and works
169 on every browser), so the blink is impossible to miss. The
170 pill fades from fully visible to ~40% and back over 2 seconds. */
171 @keyframes cw-box-blink {
172 0%, 100% { opacity: 1; }
173 50% { opacity: 0.40; }
174 }
175 /* JS-triggered bump when the count goes up. Brief scale on top
176 of the box blink so a new chat literally pops into view. */
177 .chat-pill.cw-active.cw-bump { animation: cw-bump 0.45s cubic-bezier(0.34, 1.56, 0.64, 1), cw-box-blink 2.0s ease-in-out infinite; }
178 @keyframes cw-bump {
179 0% { transform: scale(1); }
180 50% { transform: scale(1.16); }
181 100% { transform: scale(1); }
182 }
183
184 /* Messages pill -- mirrors the chat-pill idle style so the three
185 topbar controls (Chat, Messages, Help) read as one consistent
186 row. Unread count gets a red badge inside the pill instead of
187 the old corner dot so the number is visible at a glance. */
188 .msg-pill {
189 position: relative;
190 display: inline-flex;
191 align-items: center;
192 gap: 9px;
193 padding: 0 14px;
194 height: 36px;
195 border-radius: 10px;
196 font-size: 12px;
197 font-weight: 700;
198 letter-spacing: 0.5px;
199 text-transform: uppercase;
200 text-decoration: none;
201 background: linear-gradient(135deg, rgba(59,130,246,0.18) 0%, rgba(37,99,235,0.12) 100%);
202 border: 1px solid rgba(59,130,246,0.45);
203 color: #93c5fd;
204 box-shadow: 0 0 10px rgba(37,99,235,0.18);
205 transition: opacity 0.45s ease-in-out, background 0.18s, color 0.18s, transform 0.12s, box-shadow 0.18s, border-color 0.18s;
206 }
207 /* JS toggles this on/off every 900ms when has-unread is set,
208 giving the same smooth fade as the active chat pill but in
209 blue. Stays static when there's nothing unread. */
210 .msg-pill.has-unread.msg-blink-off { opacity: 0.65; }
211 .msg-pill .msg-badge.msg-badge-empty { display: none; }
212 .msg-pill:hover {
213 background: linear-gradient(135deg, rgba(59,130,246,0.28) 0%, rgba(37,99,235,0.20) 100%);
214 color: #fff;
215 transform: translateY(-1px);
216 box-shadow: 0 0 16px rgba(37,99,235,0.45);
217 }
218 .msg-pill:focus-visible {
219 outline: 2px solid #60a5fa;
220 outline-offset: 2px;
221 }
222 .msg-pill .msg-dot {
223 width: 9px; height: 9px;
224 border-radius: 50%;
225 background: #3b82f6;
226 box-shadow: 0 0 6px rgba(59,130,246,0.7);
227 flex-shrink: 0;
228 }
229 .msg-pill .msg-badge {
230 display: inline-flex;
231 align-items: center;
232 justify-content: center;
233 min-width: 18px; height: 18px;
234 padding: 0 6px;
235 border-radius: 9px;
236 background: #ef4444;
237 color: #fff;
238 font-size: 11px;
239 font-weight: 800;
240 line-height: 1;
241 box-shadow: 0 0 8px rgba(239,68,68,0.55);
242 margin-left: 2px;
243 }
244
245 /* Prominent topbar Help button. More visible than a plain icon-btn
246 so first-time users can find docs / how-to without hunting. Blue
247 chip with icon + label + soft glow. */
248 .help-btn {
249 display: inline-flex;
250 align-items: center;
251 gap: 7px;
252 height: 36px;
253 padding: 0 14px;
254 border-radius: 10px;
255 background: linear-gradient(135deg, rgba(59,130,246,0.22) 0%, rgba(37,99,235,0.18) 100%);
256 border: 1px solid rgba(59,130,246,0.55);
257 color: #93c5fd;
258 font-size: 13px;
259 font-weight: 700;
260 letter-spacing: 0.3px;
261 text-decoration: none;
262 box-shadow: 0 0 14px rgba(37,99,235,0.32);
263 transition: background 0.15s, color 0.15s, transform 0.12s, box-shadow 0.15s;
264 }
265 .help-btn svg { width: 16px; height: 16px; flex-shrink: 0; }
266 .help-btn:hover {
267 background: linear-gradient(135deg, rgba(59,130,246,0.32) 0%, rgba(37,99,235,0.28) 100%);
268 color: #fff;
269 transform: translateY(-1px);
270 box-shadow: 0 0 18px rgba(37,99,235,0.55);
271 }
272 .help-btn:focus-visible {
273 outline: 2px solid #60a5fa;
274 outline-offset: 2px;
275 }
276 /* Chevron next to the Help label hints at the dropdown. Rotates
277 slightly on hover so the open-state reads visually. */
278 .help-btn-chevron {
279 width: 12px !important; height: 12px !important;
280 opacity: 0.85;
281 transition: transform 0.15s, opacity 0.15s;
282 }
283 .help-menu:hover .help-btn-chevron,
284 .help-menu:focus-within .help-btn-chevron {
285 transform: rotate(180deg);
286 opacity: 1;
287 }
288
289 /* Help-menu dropdown -- mirrors the profile-menu pattern so the two
290 feel like one family. Hover the chip to reveal; clicking the chip
291 still navigates to /help.cgi for keyboard / no-hover users. */
292 .help-menu { position: relative; }
293 .help-dropdown {
294 position: absolute;
295 top: calc(100% + 8px);
296 right: 0;
297 min-width: 240px;
298 background: var(--col-surface-2);
299 border: 1px solid var(--col-border);
300 border-radius: 12px;
301 padding: 6px 0 8px;
302 box-shadow: 0 20px 60px rgba(0,0,0,0.5);
303 opacity: 0; visibility: hidden;
304 transform: translateY(-6px);
305 transition: opacity 0.15s, transform 0.15s, visibility 0.15s;
306 z-index: 200;
307 }
308 /* Grace pad so cursor can move from button into the dropdown
309 without crossing a gap that would collapse it. */
310 .help-dropdown::before {
311 content: ""; position: absolute;
312 top: -8px; left: 0; right: 0; height: 8px;
313 }
314 .help-menu:hover .help-dropdown,
315 .help-menu:focus-within .help-dropdown {
316 opacity: 1; visibility: visible; transform: translateY(0);
317 }
318 .help-dropdown-section {
319 font-size: 9px;
320 font-weight: 800;
321 letter-spacing: 1.5px;
322 text-transform: uppercase;
323 color: var(--col-text-3);
324 padding: 10px 16px 4px;
325 }
326 .help-dropdown a {
327 display: flex; align-items: center; gap: 10px;
328 padding: 9px 16px;
329 font-size: 13px; font-weight: 600;
330 color: var(--col-text-2);
331 text-decoration: none;
332 transition: color 0.12s, background 0.12s;
333 }
334 .help-dropdown a svg {
335 width: 14px; height: 14px;
336 flex-shrink: 0;
337 color: var(--col-text-3);
338 }
339 .help-dropdown a:hover,
340 .help-dropdown a:focus-visible {
341 color: #93c5fd;
342 background: var(--col-surface-3);
343 outline: none;
344 }
345 .help-dropdown a:hover svg,
346 .help-dropdown a:focus-visible svg {
347 color: #93c5fd;
348 }
349
350 /* Edge-tab toggle. Fixed-positioned so it sits at the sidebar /
351 content boundary, vertically centered. Sliding the chevron with
352 the same transition timing as the sidebar makes it feel like
353 part of the divider rather than a floating button. */
354 .sidebar-toggle {
355 position: fixed;
356 top: 50%;
357 left: var(--sidebar-w);
358 transform: translate(-50%, -50%);
359 width: 22px; height: 56px;
360 background: var(--col-surface-2);
361 border: 1px solid var(--col-border);
362 border-radius: 8px;
363 color: var(--col-text-3);
364 display: grid; place-items: center;
365 cursor: pointer;
366 z-index: 60;
367 box-shadow: 0 4px 14px rgba(0,0,0,0.3);
368 transition: left 0.22s ease, border-color 0.15s, color 0.15s, background 0.15s, box-shadow 0.15s;
369 }
370 .sidebar-toggle:hover {
371 border-color: var(--col-accent);
372 color: var(--col-text);
373 background: var(--col-surface-3);
374 box-shadow: 0 4px 18px rgba(59,130,246,0.35);
375 }
376 .sidebar-toggle svg { width: 14px; height: 14px; transition: transform 0.2s ease; }
377 /* Chevron points left when sidebar is open ("click to collapse"),
378 flips to point right when collapsed ("click to expand"). */
379 html.sidebar-collapsed .sidebar-toggle { left: 0; }
380 html.sidebar-collapsed .sidebar-toggle svg { transform: rotate(180deg); }
381
382 /* Impersonation banner -- only rendered while an admin is acting
383 as another user. Bright accent so it's impossible to miss. */
384 .impersonation-banner {
385 display: flex; align-items: center; justify-content: space-between;
386 gap: 14px; flex-wrap: wrap;
387 padding: 10px 18px;
388 background: linear-gradient(90deg, rgba(245,158,11,0.20), rgba(239,68,68,0.20));
389 border-bottom: 1px solid rgba(245,158,11,0.45);
390 color: #fde68a; font-size: 13px;
391 }
392 .impersonation-banner-msg { display:flex; align-items:center; gap:10px; }
393 .impersonation-banner-msg svg { flex-shrink: 0; }
394 .impersonation-banner-msg strong { color: #fff; }
395 .impersonation-banner-form { margin: 0; }
396 .impersonation-banner-stop {
397 background: rgba(239,68,68,0.85);
398 border: 1px solid rgba(239,68,68,1);
399 color: #fff; font-weight: 700;
400 padding: 6px 14px; border-radius: 8px;
401 font-size: 12px; cursor: pointer;
402 letter-spacing: 0.5px;
403 }
404 .impersonation-banner-stop:hover { background: #dc2626; }
405
406 /* Top-right profile menu. Hover over the avatar to reveal the
407 dropdown; uses CSS-only hover with a small grace area so the
408 cursor can move from the avatar into the menu without it
409 collapsing. */
410 .profile-menu { position: relative; flex-shrink: 0; }
411 /* Eye-catching avatar -- vibrant gradient + animated halo so first-
412 time users naturally hover to see what's behind it. The halo is
413 a soft repeating glow at low opacity that never gets noisy. */
414 .profile-menu .avatar {
415 position: relative;
416 width: 38px; height: 38px;
417 border-radius: 50%;
418 background: linear-gradient(135deg, #3b82f6 0%, #6366f1 45%, #06b6d4 100%);
419 display: flex; align-items: center; justify-content: center;
420 font-weight: 800; font-size: 14px; color: #fff;
421 cursor: pointer;
422 letter-spacing: 0.5px;
423 user-select: none;
424 box-shadow:
425 0 0 0 2px rgba(59,130,246,0.45),
426 0 0 14px rgba(99,102,241,0.55);
427 transition: transform 0.18s ease, box-shadow 0.18s ease;
428 }
429 .profile-menu .avatar::before {
430 content: '';
431 position: absolute;
432 inset: -6px;
433 border-radius: 50%;
434 background: radial-gradient(circle, rgba(99,102,241,0.45) 0%, rgba(59,130,246,0.0) 70%);
435 animation: avatar-pulse 2.6s ease-in-out infinite;
436 pointer-events: none;
437 z-index: -1;
438 }
439 @keyframes avatar-pulse {
440 0%, 100% { transform: scale(1); opacity: 0.55; }
441 50% { transform: scale(1.25); opacity: 0.95; }
442 }
443 .profile-menu .avatar:hover {
444 transform: scale(1.08);
445 box-shadow:
446 0 0 0 3px rgba(99,102,241,0.65),
447 0 0 22px rgba(59,130,246,0.75);
448 }
449 .profile-menu:focus-within .avatar {
450 transform: scale(1.05);
451 outline: 2px solid #60a5fa;
452 outline-offset: 3px;
453 }
454 /* Respect reduced-motion preferences -- still highlight the avatar
455 but drop the animated halo for users who opted out. */
456 @media (prefers-reduced-motion: reduce) {
457 .profile-menu .avatar::before { animation: none; }
458 }
459 .profile-dropdown {
460 position: absolute;
461 top: calc(100% + 8px);
462 right: 0;
463 min-width: 240px;
464 background: var(--col-surface-2);
465 border: 1px solid var(--col-border);
466 border-radius: 12px;
467 padding: 6px 0;
468 box-shadow: 0 20px 60px rgba(0,0,0,0.5);
469 opacity: 0; visibility: hidden;
470 transform: translateY(-6px);
471 transition: opacity 0.15s, transform 0.15s, visibility 0.15s;
472 z-index: 200;
473 /* Cap height to the viewport (minus the topbar + a small margin)
474 and scroll the contents when the menu is longer than that.
475 Admins see 25+ items across Studio / Reporting / Account /
476 Admin -- on a 13" laptop the menu used to clip the bottom
477 entries off-screen. */
478 max-height: calc(100vh - 90px);
479 overflow-y: auto;
480 overscroll-behavior: contain;
481 }
482 /* Slim dark scrollbar so it doesn't fight the dropdown chrome. */
483 .profile-dropdown::-webkit-scrollbar { width: 8px; }
484 .profile-dropdown::-webkit-scrollbar-track { background: transparent; }
485 .profile-dropdown::-webkit-scrollbar-thumb {
486 background: rgba(148,163,184,0.25); border-radius: 4px;
487 }
488 .profile-dropdown::-webkit-scrollbar-thumb:hover {
489 background: rgba(148,163,184,0.45);
490 }
491 /* Grace pad so cursor can move from avatar to dropdown without it
492 collapsing in the gap between them. */
493 .profile-dropdown::before {
494 content: ""; position: absolute;
495 top: -8px; left: 0; right: 0; height: 8px;
496 }
497 .profile-menu:hover .profile-dropdown,
498 .profile-menu:focus-within .profile-dropdown {
499 opacity: 1; visibility: visible; transform: translateY(0);
500 }
501 .profile-dropdown-header {
502 display: flex; align-items: center; gap: 12px;
503 padding: 12px 16px 14px;
504 }
505 .profile-dropdown-avatar {
506 width: 40px; height: 40px;
507 border-radius: 50%;
508 background: linear-gradient(135deg, var(--col-accent), #06b6d4);
509 display: flex; align-items: center; justify-content: center;
510 font-weight: 700; color: #fff;
511 flex-shrink: 0;
512 }
513 .profile-dropdown-name {
514 margin: 0;
515 font-size: 14px; font-weight: 700;
516 color: var(--col-text);
517 line-height: 1.3;
518 }
519 .profile-dropdown-email {
520 margin: 0;
521 font-family: var(--font-mono, monospace);
522 font-size: 11px;
523 color: var(--col-text-dim);
524 line-height: 1.3;
525 word-break: break-all;
526 }
527 .profile-dropdown-divider {
528 height: 1px;
529 background: var(--col-border);
530 margin: 4px 0;
531 }
532 /* Section header label for the profile dropdown -- same visual
533 language as the sidebar section labels so the two menus read as
534 part of one family. */
535 .profile-dropdown-section {
536 font-size: 9px;
537 font-weight: 800;
538 letter-spacing: 1.5px;
539 text-transform: uppercase;
540 color: var(--col-text-3);
541 padding: 10px 16px 4px;
542 }
543 .profile-dropdown a {
544 display: flex; align-items: center; gap: 8px;
545 padding: 9px 16px;
546 font-size: 13px; font-weight: 600;
547 color: var(--col-text-2);
548 text-decoration: none;
549 transition: color 0.12s, background 0.12s;
550 }
551 .profile-dropdown a:hover {
552 color: var(--col-accent-bright);
553 background: var(--col-surface-3);
554 }
555 .profile-dropdown a.profile-admin { color: #a78bfa; }
556 .profile-dropdown a.profile-signout { color: #f87171; }
557 .profile-dropdown a.profile-signout:hover { color: #fca5a5; background: rgba(239,68,68,0.08); }
558 .profile-dropdown .glyph {
559 display: inline-block; width: 18px; text-align: center;
560 color: var(--col-text-3);
561 font-size: 13px;
562 }
563 .profile-dropdown a:hover .glyph { color: var(--col-accent-bright); }
564 /* Auto-generated dropdown items use the same SVG icons as the
565 sidebar (rendered via _build_dropdown_links -> _icon). Match the
566 glyph sizing + hover color so the visual rhythm stays consistent. */
567 .profile-dropdown .dropdown-icon {
568 display: inline-flex; align-items: center; justify-content: center;
569 width: 18px; height: 18px; flex-shrink: 0;
570 color: var(--col-text-3);
571 }
572 .profile-dropdown .dropdown-icon svg { width: 16px; height: 16px; stroke: currentColor; }
573 .profile-dropdown a:hover .dropdown-icon { color: var(--col-accent-bright); }
574 </style>
575 $extra_css
576</head>
577<body>
578 <div class="app">
579 <button class="sidebar-toggle" type="button" aria-label="Toggle sidebar" title="Collapse / expand sidebar">
580 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>
581 </button>
582 <aside class="sidebar">
583 <div class="sidebar-brand">
584 <a href="/dashboard.cgi" style="display:flex;align-items:center;gap:12px;text-decoration:none">
585 <div class="brand-mark" style="padding:0">
586 <!-- Isometric 3D cube. Translucent whites layered over the
587 existing purple-gradient box so the icon picks up the
588 brand color underneath. Top face is brightest, right
589 face medium, left face darkest -- standard iso shading.
590 Inspired by the user's SVG sample 2026-05. -->
591 <svg viewBox="0 0 40 50" width="26" height="32" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" style="display:block">
592 <polygon points="0,20 20,10 20,35 0,45" fill="rgba(255,255,255,0.18)"/>
593 <polygon points="20,10 40,20 40,45 20,35" fill="rgba(255,255,255,0.34)"/>
594 <polygon points="0,20 20,10 40,20 20,30" fill="rgba(255,255,255,0.62)"/>
595 </svg>
596 </div>
597 <div class="brand-text">
598 <span class="brand-name">$brand_name</span>
599 <span class="brand-sub">Affiliate platform</span>
600 </div>
601 </a>
602 </div>
603 <nav class="sidebar-nav">
604 $sidebar
605 </nav>
606 <div class="sidebar-foot">
607 <div class="user-chip">
608 <div class="user-avatar">$initials</div>
609 <div class="user-meta">
610 <div class="user-name">$display_name</div>
611 <div class="user-tier">$plan_tier Plan</div>
612 </div>
613 </div>
614 <a href="/logout.cgi" class="icon-btn" title="Sign out" aria-label="Sign out">
615 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
616 </a>
617 </div>
618 </aside>
619
620 <div class="app-main">
621 $impersonation_banner
622 <header class="topbar">
623 <!-- Topbar-only mini brand. Only visible when the sidebar is
624 collapsed so the company logo stays on-screen at all
625 times. CSS gates this on html.sidebar-collapsed. -->
626 <a href="/dashboard.cgi" class="topbar-brand" aria-label="AffSoft">
627 <div class="brand-mark" style="padding:0;width:34px;height:34px">
628 <svg viewBox="0 0 40 50" width="22" height="28" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" style="display:block">
629 <polygon points="0,20 20,10 20,35 0,45" fill="rgba(255,255,255,0.18)"/>
630 <polygon points="20,10 40,20 40,45 20,35" fill="rgba(255,255,255,0.34)"/>
631 <polygon points="0,20 20,10 40,20 20,30" fill="rgba(255,255,255,0.62)"/>
632 </svg>
633 </div>
634 <span class="topbar-brand-name">$brand_name</span>
635 </a>
636 <div class="topbar-title">$title</div>
637 <div class="topbar-tools">
638 <form class="search-box" method="GET" action="/search.cgi" role="search">
639 <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
640 <input type="text" name="q" placeholder="Search models, orders, settings..." autocomplete="off">
641 </form>
642 [if:$is_admin]
643 <a id="chat-waiting-pill" class="chat-pill[if:$has_admin_chats_pending] cw-active[/if][if:!$has_admin_chats_pending] cw-idle[/if]" href="$admin_chats_href" title="Live chat">
644 <span class="cw-ping" aria-hidden="true"></span>
645 <span>Chat</span>
646 <span class="cw-badge[if:!$has_admin_chats_pending] cw-badge-empty[/if]"><span class="cw-count">$admin_chats_pending</span></span>
647 </a>
648 [/if]
649 <a id="msg-pill" class="msg-pill[if:$has_unread_messages] has-unread[/if]" href="$messages_href" title="Messages" aria-label="Messages">
650 <span class="msg-dot" aria-hidden="true"></span>
651 <span>Messages</span>
652 <span class="msg-badge[if:!$has_unread_messages] msg-badge-empty[/if]" data-msg-count>$unread_messages_count</span>
653 </a>
654 <!-- Help-menu: the topbar Help button is the hover anchor for
655 a dropdown that surfaces every help destination. Clicking
656 the button itself still goes to /help.cgi (the resource
657 hub) as a fallback for keyboard / no-hover usage. -->
658 <div class="help-menu" tabindex="0">
659 <a class="help-btn" href="/help.cgi" title="Open the help &amp; resources guide" aria-label="Help" aria-haspopup="menu">
660 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M9.1 9a3 3 0 0 1 5.8 1c0 2-3 3-3 3"/><path d="M12 17h0"/></svg>
661 <span>Help</span>
662 <svg class="help-btn-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
663 </a>
664 <div class="help-dropdown" role="menu">
665 <div class="help-dropdown-section">Help</div>
666 <a href="/help.cgi" role="menuitem">
667 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M9.1 9a3 3 0 0 1 5.8 1c0 2-3 3-3 3"/><path d="M12 17h0"/></svg>
668 Help center
669 </a>
670 <a href="/tutorials.cgi" role="menuitem">
671 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>
672 Tutorials
673 </a>
674 <a href="/tutorials.cgi#section-optimization" role="menuitem">
675 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
676 A/B &amp; MVT tutorials
677 </a>
678 <a href="/tour.cgi" role="menuitem">
679 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2 2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
680 Product tour
681 </a>
682 </div>
683 </div>
684
685 <!-- Profile menu. Hover the avatar to reveal the dropdown.
686 The Admin Console entry is gated on $is_admin so sellers
687 never see it. -->
688 <div class="profile-menu" tabindex="0">
689 <div class="avatar" title="My account">$initials</div>
690 <div class="profile-dropdown" role="menu">
691 <div class="profile-dropdown-header">
692 <div class="profile-dropdown-avatar">$initials</div>
693 <div style="min-width:0">
694 <p class="profile-dropdown-name">$display_name</p>
695 <p class="profile-dropdown-email">$email</p>
696 </div>
697 </div>
698 <!-- Sections + items auto-generated from @SIDEBAR in
699 MODS::AffSoft::Wrapper. Never hand-edit these links
700 -- they MUST stay in lockstep with the left sidebar.
701 To add / rename / remove an item, change @SIDEBAR. -->
702 $dropdown_links
703 <div class="profile-dropdown-divider"></div>
704 <a href="/logout.cgi" class="profile-signout"><span class="glyph">&times;</span> Sign Out</a>
705 </div>
706 </div>
707 </div>
708 </header>
709
710 <main class="app-content">
711 $body
712 </main>
713 </div>
714 </div>
715
716 <script src="$assets_js/site.js?v=4"></script>
717 <!-- In-app confirm modal -- replaces native window.confirm() across
718 the dashboard. Auto-binds any form/link/button with
719 data-confirm-title attrs and exposes window.appConfirm() for
720 imperative calls. Loads on every authenticated page so popups
721 feel consistent. -->
722 <script src="$assets_js/confirm.js?v=1"></script>
723 <!-- track.js (visitor tracking + chat widget) is intentionally NOT
724 loaded in the dashboard wrapper. The admin is the one running
725 chat from /admin_chat.cgi -- they should not see the bubble on
726 their own admin pages, and their own dashboard sessions should
727 not pollute the visitor analytics. track.js is loaded by
728 webstls_marketing.html for customer-facing pages. -->
729
730 <script>
731 // Sidebar collapse toggle. The per-user preference is stored in
732 // user_settings.sidebar_collapsed -- Wrapper.pm renders the
733 // current value as a class on <html>, so the next page load is
734 // already in the right state on first paint. We POST the new
735 // value with keepalive:true so the save survives the user
736 // clicking a nav link right after toggling.
737 (function () {
738 var btn = document.querySelector('.sidebar-toggle');
739 if (!btn) return;
740 var html = document.documentElement;
741 // Matches the mobile breakpoint in site.css. Kept in one place via
742 // matchMedia so a future width change only updates the CSS.
743 var mqMobile = window.matchMedia('(max-width: 1200px)');
744
745 btn.addEventListener('click', function (e) {
746 if (mqMobile.matches) {
747 // On mobile the toggle controls the slide-in overlay --
748 // a separate class so it doesn't pollute the per-user
749 // sidebar_collapsed preference used on desktop.
750 html.classList.toggle('mobile-nav-open');
751 e.stopPropagation();
752 return;
753 }
754 var collapsed = html.classList.toggle('sidebar-collapsed');
755 try {
756 fetch('/save_sidebar.cgi', {
757 method: 'POST',
758 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
759 body: 'sidebar_collapsed=' + (collapsed ? '1' : '0'),
760 keepalive: true,
761 credentials: 'same-origin'
762 });
763 } catch (e2) {}
764 });
765
766 // Close the mobile overlay when the user taps outside the sidebar
767 // (e.g. the dim backdrop) or follows a nav link inside it.
768 document.addEventListener('click', function (e) {
769 if (!html.classList.contains('mobile-nav-open')) return;
770 if (e.target.closest('.sidebar-toggle')) return; // handled above
771 if (e.target.closest('.sidebar a')) { // nav link tapped
772 html.classList.remove('mobile-nav-open');
773 return;
774 }
775 if (!e.target.closest('.sidebar')) { // backdrop tap
776 html.classList.remove('mobile-nav-open');
777 }
778 });
779
780 // If the viewport crosses back to desktop (rotation, fold open,
781 // window resize), drop the mobile-open state so we don't strand
782 // it stuck after the slide-in styles stop applying.
783 mqMobile.addEventListener('change', function (ev) {
784 if (!ev.matches) html.classList.remove('mobile-nav-open');
785 });
786 })();
787
788 // Live-poll the topbar chat pill so admins see state change
789 // WITHOUT having to refresh the page. 20s cadence keeps it cheap;
790 // the endpoint is a single indexed COUNT(). Two states:
791 // - idle (blue, "Chat", links to /admin_chat.cgi)
792 // - active (green, "N waiting", links straight to oldest chat)
793 // The pill bumps on every count increase so a brand-new chat is
794 // impossible to miss; a decreasing count never re-alerts.
795 (function () {
796 var pill = document.getElementById('chat-waiting-pill');
797 if (!pill) return;
798 var countEl = pill.querySelector('.cw-count');
799 var badgeEl = pill.querySelector('.cw-badge');
800 var POLL_MS = 20000;
801 var lastN = parseInt(countEl && countEl.textContent, 10) || 0;
802 var bumpTimer;
803 function paint(n, firstId) {
804 n = (typeof n === 'number' && n >= 0) ? n : 0;
805 firstId = (typeof firstId === 'number' && firstId > 0) ? firstId : 0;
806 if (n <= 0) {
807 // Idle: blue pill, no badge, link to chat console.
808 pill.classList.remove('cw-active');
809 pill.classList.remove('cw-bump');
810 pill.classList.add('cw-idle');
811 pill.setAttribute('href', '/admin_chat.cgi');
812 pill.setAttribute('title', 'Live chat');
813 if (badgeEl) badgeEl.classList.add('cw-badge-empty');
814 lastN = 0;
815 return;
816 }
817 // Active: green pulsing pill with red count badge.
818 if (n !== lastN && countEl) countEl.textContent = n;
819 if (badgeEl) badgeEl.classList.remove('cw-badge-empty');
820 pill.classList.remove('cw-idle');
821 pill.classList.add('cw-active');
822 pill.setAttribute(
823 'href',
824 firstId ? ('/admin_chat.cgi?chat=' + firstId) : '/admin_chat.cgi?view=active'
825 );
826 pill.setAttribute('title', n + (n === 1 ? ' visitor' : ' visitors') + ' waiting for a reply');
827 // Bump only when the count INCREASED -- a decreasing count
828 // (admin replied, chat closed) shouldn't visually re-alert.
829 if (n > lastN) {
830 pill.classList.remove('cw-bump');
831 void pill.offsetWidth; // restart animation
832 pill.classList.add('cw-bump');
833 clearTimeout(bumpTimer);
834 bumpTimer = setTimeout(function () {
835 pill.classList.remove('cw-bump');
836 }, 500);
837 }
838 lastN = n;
839 }
840 function tick() {
841 fetch('/admin_chat_pending.cgi', {
842 credentials: 'same-origin',
843 cache: 'no-store'
844 })
845 .then(function (r) { return r.ok ? r.json() : null; })
846 .then(function (d) {
847 if (d && typeof d.n === 'number') paint(d.n, d.first_id || 0);
848 })
849 .catch(function () { /* offline / network blip -- next tick retries */ });
850 }
851 setInterval(tick, POLL_MS);
852 // Also tick once when the tab regains focus so coming back to
853 // a backgrounded tab gives an immediate fresh count.
854 window.addEventListener('focus', tick);
855
856 // JS-driven blink while active. CSS @keyframes can be silenced
857 // by OS-level prefers-reduced-motion / some VM browsers; this
858 // toggle is a direct DOM class change so it always runs. The
859 // CSS rule attaches a 0.45s opacity transition so the toggle
860 // reads as a smooth fade, not a hard flicker. Same tick also
861 // The chat pill is admin-only. The Messages pill polling +
862 // blink lives in its OWN IIFE below so non-admin users (who
863 // don't have the chat pill, and would early-return out of
864 // this whole block) still get a live-updating Messages pill.
865 setInterval(function () {
866 if (pill.classList.contains('cw-active')) {
867 pill.classList.toggle('cw-blink-off');
868 } else {
869 pill.classList.remove('cw-blink-off');
870 }
871 }, 900);
872 })();
873
874 // ===== Messages pill: live-poll + blink (admin AND non-admin) =====
875 // Independent IIFE so non-admins (who don't have the chat pill
876 // above) still get the Messages pill blinking and live-updating.
877 (function () {
878 var msgPill = document.getElementById('msg-pill');
879 if (!msgPill) return;
880 var msgBadge = msgPill.querySelector('[data-msg-count]');
881 // Synced blink tick at 900ms (matches chat pill cadence so
882 // when both are visible they breathe together).
883 setInterval(function () {
884 if (msgPill.classList.contains('has-unread')) {
885 msgPill.classList.toggle('msg-blink-off');
886 } else {
887 msgPill.classList.remove('msg-blink-off');
888 }
889 }, 900);
890 function msgTick() {
891 fetch('/messages_pending.cgi', { credentials:'same-origin', cache:'no-store' })
892 .then(function(r){ return r.ok ? r.json() : null; })
893 .then(function(d){
894 if (!d || typeof d.n !== 'number') return;
895 var n = d.n;
896 if (msgBadge) msgBadge.textContent = n;
897 if (n > 0) {
898 msgPill.classList.add('has-unread');
899 if (msgBadge) msgBadge.classList.remove('msg-badge-empty');
900 } else {
901 msgPill.classList.remove('has-unread');
902 msgPill.classList.remove('msg-blink-off');
903 if (msgBadge) msgBadge.classList.add('msg-badge-empty');
904 }
905 })
906 .catch(function(){});
907 }
908 setInterval(msgTick, 20000);
909 window.addEventListener('focus', msgTick);
910 })();
911
912 // Preserve sidebar scroll position across page reloads. Some
913 // pages (Traffic Statistics, Visitors, etc.) auto-refresh every
914 // 15s; without this, the menu snaps back to the top mid-click
915 // and the user has to re-scroll. Saved per browser tab so
916 // different tabs can scroll independently.
917 (function () {
918 var nav = document.querySelector('.sidebar-nav');
919 if (!nav) return;
920 var KEY = 'webstls_sidebar_scroll';
921 var saved = sessionStorage.getItem(KEY);
922 if (saved !== null) {
923 var n = parseInt(saved, 10);
924 if (!isNaN(n) && n >= 0) nav.scrollTop = n;
925 }
926 var pending;
927 nav.addEventListener('scroll', function () {
928 clearTimeout(pending);
929 pending = setTimeout(function () {
930 try { sessionStorage.setItem(KEY, nav.scrollTop); } catch (e) {}
931 }, 120);
932 }, { passive: true });
933 // Also save right before unload so a fast click-then-navigate
934 // captures the latest position even if the debounced save
935 // hadn't fired yet.
936 window.addEventListener('beforeunload', function () {
937 try { sessionStorage.setItem(KEY, nav.scrollTop); } catch (e) {}
938 });
939 })();
940 </script>
941 $extra_js
942 <script>
943 // Timezone pingback. Reads the browser's IANA timezone via the
944 // Intl API and POSTs it to /_set_timezone.cgi once per session.
945 // The endpoint updates users.timezone so every reporting query
946 // returns values in the viewer's local time (MariaDB SET time_zone
947 // applies via MODS::DBConnect on every fresh connection).
948 (function(){
949 try {
950 var tz = (Intl && Intl.DateTimeFormat) ? Intl.DateTimeFormat().resolvedOptions().timeZone : '';
951 if (!tz) return;
952 var sent = '';
953 try { sent = sessionStorage.getItem('webstls_tz_sent'); } catch(e){}
954 if (sent === tz) return;
955 var fd = new FormData();
956 fd.append('tz', tz);
957 fetch('/_set_timezone.cgi', { method: 'POST', body: fd, credentials: 'same-origin' })
958 .then(function(){ try { sessionStorage.setItem('webstls_tz_sent', tz); } catch(e){} })
959 .catch(function(){});
960 } catch(e) {}
961 })();
962 </script>
963</body>
964</html>
Keyboard: j next diff k previous diff g top G bottom r restore c compile-check ? help