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

O Operator
Diff

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

added on local at 2026-07-11 08:13:40

Added
+1187
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to e6b535a9f7b7
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="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect x='0.75' y='0.75' width='30.5' height='30.5' rx='7.5' fill='%232a1547' stroke='white' stroke-width='1.5'/><text x='16' y='22' font-family='Arial,Helvetica,sans-serif' font-size='14' font-weight='700' fill='white' text-anchor='middle'>AS</text></svg>">
12 <link rel="preconnect" href="https://fonts.googleapis.com">
13 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
14 <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">
15 <link rel="stylesheet" href="$assets_css/site.css?v=2026-07-02-padfix">
16 <style>
17 /* ── Collapsible sidebar (ShopCart icon-rail pattern) ──
18 Default 240px expanded; collapsed shrinks to a 48px rail
19 showing only icons + brand mark + avatar. Section labels
20 turn into thin purple divider bars; nav-link labels and
21 badges are hidden. */
22 :root { --sidebar-rail-w: 48px; }
23 .app { transition: grid-template-columns 0.22s ease; }
24 .sidebar { transition: padding 0.22s ease; overflow-x: hidden; }
25
26 html.sidebar-collapsed .app { grid-template-columns: var(--sidebar-rail-w) 1fr; }
27
28 /* When collapsed: hide all text, center icons, shrink horizontal padding. */
29 html.sidebar-collapsed .sidebar { padding-left: 0 !important; padding-right: 0 !important; }
30 html.sidebar-collapsed .sidebar-brand { justify-content: center; padding-left: 4px; padding-right: 4px; }
31 html.sidebar-collapsed .sidebar-brand .brand-mark { width: 34px; height: 34px; }
32 html.sidebar-collapsed .sidebar-brand .brand-mark svg { width: 22px; height: 28px; }
33 html.sidebar-collapsed .sidebar-brand a { gap: 0; }
34 html.sidebar-collapsed .brand-text,
35 html.sidebar-collapsed .sidebar-section-label,
36 html.sidebar-collapsed .sidebar-link span,
37 html.sidebar-collapsed .sidebar-link .badge,
38 html.sidebar-collapsed .user-meta,
39 html.sidebar-collapsed .sidebar-foot .icon-btn { display: none; }
40 html.sidebar-collapsed .sidebar-nav { padding-left: 6px; padding-right: 6px; }
41 html.sidebar-collapsed .sidebar-link {
42 justify-content: center;
43 padding: 11px 0;
44 gap: 0;
45 }
46 html.sidebar-collapsed .sidebar-link svg { width: 20px; height: 20px; }
47 /* Keep the active accent bar visible but flush against the rail's left edge. */
48 html.sidebar-collapsed .sidebar-link.active::before { left: -6px; }
49 /* Section dividers — give the collapsed sections a faint top border
50 since we lose the text label that visually separated them. */
51 html.sidebar-collapsed .sidebar-section-label {
52 display: block !important;
53 visibility: hidden;
54 height: 1px;
55 padding: 0;
56 margin: 12px 14px 6px;
57 background: rgba(124,58,237,0.22);
58 }
59 html.sidebar-collapsed .sidebar-foot {
60 flex-direction: column;
61 padding: 12px 6px;
62 gap: 8px;
63 }
64 html.sidebar-collapsed .sidebar-foot .user-chip { justify-content: center; }
65 html.sidebar-collapsed .user-avatar { width: 32px; height: 32px; font-size: 12px; }
66
67 /* Tooltip on hover when collapsed — shows the link label using the
68 link's title attribute, positioned to the right of the rail. */
69 html.sidebar-collapsed .sidebar-link { position: relative; }
70 html.sidebar-collapsed .sidebar-link[title]:hover::after {
71 content: attr(title);
72 position: absolute;
73 left: calc(100% + 14px);
74 top: 50%;
75 transform: translateY(-50%);
76 padding: 6px 10px;
77 background: var(--col-surface-3);
78 border: 1px solid var(--col-border-2);
79 color: var(--col-text);
80 border-radius: 6px;
81 font-size: 12px;
82 font-weight: 600;
83 white-space: nowrap;
84 box-shadow: 0 4px 14px rgba(0,0,0,0.4);
85 z-index: 200;
86 pointer-events: none;
87 }
88
89 /* When collapsed the content area is wider — drop the centered cap. */
90 html.sidebar-collapsed .app-content { max-width: none; padding-left: 24px; padding-right: 24px; }
91 html.sidebar-collapsed .topbar { padding-left: 24px; padding-right: 24px; }
92
93 /* Mobile / tablet: hide the sidebar entirely via slide-out overlay. */
94 @media (max-width: 1200px) {
95 .app { grid-template-columns: 0 1fr; }
96 .sidebar {
97 position: fixed; top: 0; bottom: 0; left: 0;
98 width: var(--sidebar-w);
99 transform: translateX(-100%);
100 transition: transform 0.22s ease;
101 z-index: 80;
102 box-shadow: 4px 0 24px rgba(0,0,0,0.4);
103 }
104 html.mobile-nav-open .sidebar { transform: translateX(0); }
105 html.mobile-nav-open body::after {
106 content: '';
107 position: fixed; inset: 0;
108 background: rgba(0,0,0,0.55);
109 z-index: 70;
110 }
111 }
112
113 /* Topbar mini brand. Hidden when the sidebar is open (sidebar carries
114 the logo) and shown only at extreme widths where the sidebar is
115 gone entirely. With the icon-rail, the brand mark stays visible in
116 the sidebar itself, so we DON'T show the topbar brand when
117 html.sidebar-collapsed. */
118 .topbar-brand {
119 display: none;
120 align-items: center;
121 gap: 10px;
122 text-decoration: none;
123 color: inherit;
124 margin-right: 14px;
125 }
126 .topbar-brand-name {
127 font-family: var(--font-display, inherit);
128 font-size: 15px;
129 font-weight: 700;
130 color: #fff;
131 letter-spacing: 0.3px;
132 }
133 @media (max-width: 1200px) {
134 .topbar-brand { display: inline-flex; }
135 }
136
137 /* Hamburger button — lives at the left of the topbar.
138 At rest: transparent / borderless, just the icon glyph.
139 On hover: bright purple gradient chip + border + glow.
140 Same .sidebar-toggle-btn class the JS already binds. */
141 .sidebar-toggle-btn,
142 .topbar-hamburger {
143 flex-shrink: 0;
144 width: 38px; height: 38px;
145 background: transparent;
146 border: 1px solid transparent;
147 border-radius: 10px;
148 color: var(--col-text-2);
149 display: inline-flex; align-items: center; justify-content: center;
150 cursor: pointer;
151 box-shadow: none;
152 transition: background 0.15s, border-color 0.15s, color 0.15s, box-shadow 0.15s, filter 0.15s, transform 0.12s;
153 }
154 .sidebar-toggle-btn:hover,
155 .topbar-hamburger:hover {
156 background: transparent;
157 border-color: var(--col-accent);
158 color: var(--col-accent-bright);
159 box-shadow: none;
160 }
161 .sidebar-toggle-btn:active,
162 .topbar-hamburger:active { transform: translateY(1px); }
163 .sidebar-toggle-btn svg,
164 .topbar-hamburger svg { width: 18px; height: 18px; }
165 /* topbar-left wraps the hamburger + page title at left of topbar. */
166 .topbar-left { display: flex; align-items: center; gap: 14px; }
167
168 /* Admin chat pill -- always-present topbar control with two
169 states: idle (blue, calm) when the queue is empty, and active
170 (green, pulsing) when visitors are waiting. The DOM is the
171 same in both states; JS just swaps cw-idle/cw-active classes
172 so transitions animate cleanly when a new chat lands or gets
173 resolved. */
174 .chat-pill {
175 position: relative;
176 display: inline-flex;
177 align-items: center;
178 gap: 9px;
179 padding: 0 14px;
180 height: 36px;
181 border-radius: 10px;
182 font-size: 12px;
183 font-weight: 700;
184 letter-spacing: 0.5px;
185 text-transform: uppercase;
186 text-decoration: none;
187 transition: background 0.18s, color 0.18s, transform 0.12s, box-shadow 0.18s, border-color 0.18s;
188 }
189 .chat-pill .cw-count { font-weight: 800; color: #fff; line-height: 1; }
190 .chat-pill .cw-badge {
191 display: inline-flex;
192 align-items: center;
193 color: #fff;
194 font-size: 12px;
195 font-weight: 800;
196 line-height: 1;
197 margin-left: 4px;
198 }
199 .chat-pill .cw-badge.cw-badge-empty { display: none; }
200 .cw-ping {
201 position: relative;
202 width: 9px; height: 9px;
203 border-radius: 50%;
204 flex-shrink: 0;
205 transition: background 0.18s, box-shadow 0.18s;
206 }
207 .cw-ping::before,
208 .cw-ping::after {
209 content: '';
210 position: absolute;
211 inset: 0;
212 border-radius: 50%;
213 opacity: 0;
214 pointer-events: none;
215 }
216 /* --- Idle: blue, no animation -------------------------------- */
217 .chat-pill.cw-idle {
218 background: linear-gradient(135deg, rgba(124,58,237,0.18) 0%, rgba(91,33,182,0.12) 100%);
219 border: 1px solid rgba(124,58,237,0.45);
220 color: #c4b5fd;
221 box-shadow: 0 0 10px rgba(91,33,182,0.18);
222 }
223 .chat-pill.cw-idle:hover {
224 background: linear-gradient(135deg, rgba(124,58,237,0.28) 0%, rgba(91,33,182,0.20) 100%);
225 color: #fff;
226 transform: translateY(-1px);
227 box-shadow: 0 0 16px rgba(91,33,182,0.45);
228 }
229 .chat-pill.cw-idle:focus-visible {
230 outline: 2px solid #a78bfa;
231 outline-offset: 2px;
232 }
233 .chat-pill.cw-idle .cw-ping {
234 background: #7c3aed;
235 box-shadow: 0 0 6px rgba(124,58,237,0.7);
236 }
237
238 /* --- Active: green, pulsing dot + count badge ----------------- */
239 .chat-pill.cw-active {
240 background: linear-gradient(135deg, rgba(16,185,129,0.28) 0%, rgba(16,185,129,0.16) 100%);
241 border: 1px solid rgba(16,185,129,0.70);
242 color: #fff;
243 box-shadow: 0 0 18px rgba(16,185,129,0.45);
244 transition: opacity 0.45s ease-in-out, background 0.18s, color 0.18s, transform 0.12s, box-shadow 0.18s, border-color 0.18s;
245 }
246 /* JS toggles this class on/off every second while active, giving
247 a fade-in/fade-out blink that can't be killed by OS-level
248 prefers-reduced-motion (which silences CSS @keyframes). */
249 .chat-pill.cw-active.cw-blink-off { opacity: 0.65; }
250 .chat-pill.cw-active:hover {
251 background: linear-gradient(135deg, rgba(16,185,129,0.34) 0%, rgba(16,185,129,0.20) 100%);
252 color: #fff;
253 transform: translateY(-1px);
254 box-shadow: 0 0 22px rgba(16,185,129,0.65);
255 }
256 .chat-pill.cw-active:focus-visible {
257 outline: 2px solid #34d399;
258 outline-offset: 2px;
259 }
260 .chat-pill.cw-active .cw-ping {
261 background: #10b981;
262 box-shadow: 0 0 8px rgba(16,185,129,0.9);
263 }
264 .chat-pill.cw-active .cw-ping::before,
265 .chat-pill.cw-active .cw-ping::after {
266 background: #10b981;
267 animation: cw-ping 1.8s cubic-bezier(0.0, 0.0, 0.2, 1) infinite;
268 }
269 .chat-pill.cw-active .cw-ping::after { animation-delay: 0.9s; }
270
271 @keyframes cw-ping {
272 0% { transform: scale(1); opacity: 0.7; }
273 80% { opacity: 0; }
274 100% { transform: scale(3.6); opacity: 0; }
275 }
276 /* Whole-box blink: matches what the pill looks like on hover at
277 its peak, then settles back to idle-active. Animates background,
278 border, AND box-shadow so the entire pill feels alive instead
279 of just a soft outer glow change. */
280 /* Slow opacity fade -- this animates THE single most reliable
281 CSS property (opacity has no transition competition and works
282 on every browser), so the blink is impossible to miss. The
283 pill fades from fully visible to ~40% and back over 2 seconds. */
284 @keyframes cw-box-blink {
285 0%, 100% { opacity: 1; }
286 50% { opacity: 0.40; }
287 }
288 /* JS-triggered bump when the count goes up. Brief scale on top
289 of the box blink so a new chat literally pops into view. */
290 .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; }
291 @keyframes cw-bump {
292 0% { transform: scale(1); }
293 50% { transform: scale(1.16); }
294 100% { transform: scale(1); }
295 }
296
297 /* Messages pill -- mirrors the chat-pill idle style so the three
298 topbar controls (Chat, Messages, Help) read as one consistent
299 row. Unread count gets a red badge inside the pill instead of
300 the old corner dot so the number is visible at a glance. */
301 .msg-pill {
302 position: relative;
303 display: inline-flex;
304 align-items: center;
305 gap: 9px;
306 padding: 0 14px;
307 height: 36px;
308 border-radius: 10px;
309 font-size: 12px;
310 font-weight: 700;
311 letter-spacing: 0.5px;
312 text-transform: uppercase;
313 text-decoration: none;
314 background: linear-gradient(135deg, rgba(124,58,237,0.18) 0%, rgba(91,33,182,0.12) 100%);
315 border: 1px solid rgba(124,58,237,0.45);
316 color: #c4b5fd;
317 box-shadow: 0 0 10px rgba(91,33,182,0.18);
318 transition: opacity 0.45s ease-in-out, background 0.18s, color 0.18s, transform 0.12s, box-shadow 0.18s, border-color 0.18s;
319 }
320 /* JS toggles this on/off every 900ms when has-unread is set,
321 giving the same smooth fade as the active chat pill but in
322 blue. Stays static when there's nothing unread. */
323 .msg-pill.has-unread.msg-blink-off { opacity: 0.65; }
324 .msg-pill .msg-badge.msg-badge-empty { display: none; }
325 .msg-pill:hover {
326 background: linear-gradient(135deg, rgba(124,58,237,0.28) 0%, rgba(91,33,182,0.20) 100%);
327 color: #fff;
328 transform: translateY(-1px);
329 box-shadow: 0 0 16px rgba(91,33,182,0.45);
330 }
331 .msg-pill:focus-visible {
332 outline: 2px solid #a78bfa;
333 outline-offset: 2px;
334 }
335 .msg-pill .msg-dot {
336 width: 9px; height: 9px;
337 border-radius: 50%;
338 background: #7c3aed;
339 box-shadow: 0 0 6px rgba(124,58,237,0.7);
340 flex-shrink: 0;
341 }
342 .msg-pill .msg-badge {
343 display: inline-flex;
344 align-items: center;
345 justify-content: center;
346 min-width: 18px; height: 18px;
347 padding: 0 6px;
348 border-radius: 9px;
349 background: #ef4444;
350 color: #fff;
351 font-size: 11px;
352 font-weight: 800;
353 line-height: 1;
354 box-shadow: 0 0 8px rgba(239,68,68,0.55);
355 margin-left: 2px;
356 }
357
358 /* Prominent topbar Help button. More visible than a plain icon-btn
359 so first-time users can find docs / how-to without hunting. Blue
360 chip with icon + label + soft glow. */
361 .help-btn {
362 display: inline-flex;
363 align-items: center;
364 gap: 7px;
365 height: 36px;
366 padding: 0 14px;
367 border-radius: 10px;
368 background: linear-gradient(135deg, rgba(124,58,237,0.22) 0%, rgba(91,33,182,0.18) 100%);
369 border: 1px solid rgba(124,58,237,0.55);
370 color: #c4b5fd;
371 font-size: 13px;
372 font-weight: 700;
373 letter-spacing: 0.3px;
374 text-decoration: none;
375 box-shadow: 0 0 14px rgba(91,33,182,0.32);
376 transition: background 0.15s, color 0.15s, transform 0.12s, box-shadow 0.15s;
377 }
378 .help-btn svg { width: 16px; height: 16px; flex-shrink: 0; }
379 .help-btn:hover {
380 background: linear-gradient(135deg, rgba(124,58,237,0.32) 0%, rgba(91,33,182,0.28) 100%);
381 color: #fff;
382 transform: translateY(-1px);
383 box-shadow: 0 0 18px rgba(91,33,182,0.55);
384 }
385 .help-btn:focus-visible {
386 outline: 2px solid #a78bfa;
387 outline-offset: 2px;
388 }
389 /* Chevron next to the Help label hints at the dropdown. Rotates
390 slightly on hover so the open-state reads visually. */
391 .help-btn-chevron {
392 width: 12px !important; height: 12px !important;
393 opacity: 0.85;
394 transition: transform 0.15s, opacity 0.15s;
395 }
396 .help-menu:hover .help-btn-chevron,
397 .help-menu:focus-within .help-btn-chevron {
398 transform: rotate(180deg);
399 opacity: 1;
400 }
401
402 /* Help-menu dropdown -- mirrors the profile-menu pattern so the two
403 feel like one family. Hover the chip to reveal; clicking the chip
404 still navigates to /help.cgi for keyboard / no-hover users. */
405 .help-menu { position: relative; }
406 .help-dropdown {
407 position: absolute;
408 top: calc(100% + 8px);
409 right: 0;
410 min-width: 240px;
411 background: var(--col-surface-2);
412 border: 1px solid var(--col-border);
413 border-radius: 12px;
414 padding: 6px 0 8px;
415 box-shadow: 0 20px 60px rgba(0,0,0,0.5);
416 opacity: 0; visibility: hidden;
417 transform: translateY(-6px);
418 transition: opacity 0.15s, transform 0.15s, visibility 0.15s;
419 z-index: 200;
420 }
421 /* Grace pad so cursor can move from button into the dropdown
422 without crossing a gap that would collapse it. */
423 .help-dropdown::before {
424 content: ""; position: absolute;
425 top: -8px; left: 0; right: 0; height: 8px;
426 }
427 .help-menu:hover .help-dropdown,
428 .help-menu:focus-within .help-dropdown {
429 opacity: 1; visibility: visible; transform: translateY(0);
430 }
431 .help-dropdown-section {
432 font-size: 9px;
433 font-weight: 800;
434 letter-spacing: 1.5px;
435 text-transform: uppercase;
436 color: var(--col-text-3);
437 padding: 10px 16px 4px;
438 }
439 .help-dropdown a {
440 display: flex; align-items: center; gap: 10px;
441 padding: 9px 16px;
442 font-size: 13px; font-weight: 600;
443 color: var(--col-text-2);
444 text-decoration: none;
445 transition: color 0.12s, background 0.12s;
446 }
447 .help-dropdown a svg {
448 width: 14px; height: 14px;
449 flex-shrink: 0;
450 color: var(--col-text-3);
451 }
452 .help-dropdown a:hover,
453 .help-dropdown a:focus-visible {
454 color: #c4b5fd;
455 background: var(--col-surface-3);
456 outline: none;
457 }
458 .help-dropdown a:hover svg,
459 .help-dropdown a:focus-visible svg {
460 color: #c4b5fd;
461 }
462
463 /* Edge-tab toggle. Fixed-positioned so it sits at the sidebar /
464 content boundary, vertically centered. Sliding the chevron with
465 the same transition timing as the sidebar makes it feel like
466 part of the divider rather than a floating button. */
467 /* Edge-tab toggle removed -- replaced by the topbar hamburger
468 button (TaskForge pattern). Kept the class hidden so any cached
469 pages don't render a stray ghost. */
470 .sidebar-toggle { display: none !important; }
471
472 /* Impersonation banner -- only rendered while an admin is acting
473 as another user. Bright accent so it's impossible to miss. */
474 .impersonation-banner {
475 display: flex; align-items: center; justify-content: space-between;
476 gap: 14px; flex-wrap: wrap;
477 padding: 10px 18px;
478 background: linear-gradient(90deg, rgba(245,158,11,0.20), rgba(239,68,68,0.20));
479 border-bottom: 1px solid rgba(245,158,11,0.45);
480 color: #fde68a; font-size: 13px;
481 }
482 .impersonation-banner-msg { display:flex; align-items:center; gap:10px; }
483 .impersonation-banner-msg svg { flex-shrink: 0; }
484 .impersonation-banner-msg strong { color: #fff; }
485 .impersonation-banner-form { margin: 0; }
486 .impersonation-banner-stop {
487 background: rgba(239,68,68,0.85);
488 border: 1px solid rgba(239,68,68,1);
489 color: #fff; font-weight: 700;
490 padding: 6px 14px; border-radius: 8px;
491 font-size: 12px; cursor: pointer;
492 letter-spacing: 0.5px;
493 }
494 .impersonation-banner-stop:hover { background: #dc2626; }
495
496 /* Top-right profile menu. Hover over the avatar to reveal the
497 dropdown; uses CSS-only hover with a small grace area so the
498 cursor can move from the avatar into the menu without it
499 collapsing. */
500 .profile-menu { position: relative; flex-shrink: 0; }
501
502 /* ─── Inline trigger (avatar + name + subtitle + chevron) ────────
503 Whole row is the hover target — clicking anywhere on the trigger
504 behaves the same as clicking the avatar did before. Ported from
505 ShopCart pattern 2026-06-08. */
506 .profile-trigger {
507 display: flex;
508 align-items: center;
509 gap: 12px;
510 padding: 4px 10px 4px 4px;
511 border-radius: 14px;
512 cursor: pointer;
513 user-select: none;
514 transition: background 0.18s, box-shadow 0.18s;
515 }
516 .profile-trigger:hover {
517 background: rgba(124,58,237,0.10);
518 box-shadow: inset 0 0 0 1px rgba(124,58,237,0.25);
519 }
520 .profile-menu:focus-within .profile-trigger {
521 background: rgba(124,58,237,0.14);
522 box-shadow: inset 0 0 0 1px rgba(124,58,237,0.45);
523 }
524 .profile-trigger-meta {
525 display: flex; flex-direction: column;
526 min-width: 0;
527 line-height: 1.15;
528 max-width: 200px;
529 }
530 .profile-trigger-name {
531 font-size: 13px;
532 font-weight: 700;
533 color: #fff;
534 white-space: nowrap;
535 overflow: hidden;
536 text-overflow: ellipsis;
537 }
538 .profile-trigger-role {
539 font-weight: 500;
540 color: var(--col-accent-bright);
541 opacity: 0.85;
542 }
543 .profile-trigger-sub {
544 margin-top: 2px;
545 font-size: 11px;
546 font-weight: 500;
547 letter-spacing: 0.3px;
548 color: var(--col-text-3);
549 white-space: nowrap;
550 overflow: hidden;
551 text-overflow: ellipsis;
552 }
553 .profile-chevron {
554 width: 14px; height: 14px;
555 color: var(--col-text-3);
556 flex-shrink: 0;
557 transition: transform 0.18s ease, color 0.18s;
558 }
559 .profile-menu:hover .profile-chevron,
560 .profile-menu:focus-within .profile-chevron {
561 transform: rotate(180deg);
562 color: var(--col-accent-bright);
563 }
564 /* On phones we don't have room for the name+chevron — only the
565 avatar is interactive there. */
566 @media (max-width: 720px) {
567 .profile-trigger-meta, .profile-chevron { display: none; }
568 .profile-trigger { padding: 0; gap: 0; }
569 .profile-trigger:hover, .profile-menu:focus-within .profile-trigger {
570 background: transparent; box-shadow: none;
571 }
572 }
573
574 /* ─── Badges in the dropdown header (role + plan tier) ──────── */
575 .profile-dropdown-badges {
576 display: flex; flex-wrap: wrap; gap: 6px;
577 margin-top: 8px;
578 }
579 .profile-badge {
580 display: inline-flex; align-items: center;
581 padding: 3px 9px;
582 border-radius: 999px;
583 font-size: 10px;
584 font-weight: 700;
585 letter-spacing: 0.4px;
586 line-height: 1.4;
587 white-space: nowrap;
588 }
589 .profile-badge-violet {
590 background: transparent;
591 color: var(--col-accent-bright);
592 border: 1px solid var(--col-accent);
593 }
594 .profile-badge-neutral {
595 background: transparent;
596 color: var(--col-text-2);
597 border: 1px solid var(--col-border-2);
598 }
599 /* Eye-catching avatar -- vibrant gradient + animated halo so first-
600 time users naturally hover to see what's behind it. The halo is
601 a soft repeating glow at low opacity that never gets noisy. */
602 .profile-menu .avatar {
603 position: relative;
604 width: 38px; height: 38px;
605 border-radius: 50%;
606 background: linear-gradient(135deg, #7c3aed 0%, #8b5cf6 45%, #06b6d4 100%);
607 display: flex; align-items: center; justify-content: center;
608 font-weight: 800; font-size: 14px; color: #fff;
609 cursor: pointer;
610 letter-spacing: 0.5px;
611 user-select: none;
612 box-shadow:
613 0 0 0 2px rgba(124,58,237,0.45),
614 0 0 14px rgba(139,92,246,0.55);
615 transition: transform 0.18s ease, box-shadow 0.18s ease;
616 }
617 .profile-menu .avatar::before {
618 content: '';
619 position: absolute;
620 inset: -6px;
621 border-radius: 50%;
622 background: radial-gradient(circle, rgba(139,92,246,0.45) 0%, rgba(124,58,237,0.0) 70%);
623 animation: avatar-pulse 2.6s ease-in-out infinite;
624 pointer-events: none;
625 z-index: -1;
626 }
627 @keyframes avatar-pulse {
628 0%, 100% { transform: scale(1); opacity: 0.55; }
629 50% { transform: scale(1.25); opacity: 0.95; }
630 }
631 .profile-menu .avatar:hover {
632 transform: scale(1.08);
633 box-shadow:
634 0 0 0 3px rgba(139,92,246,0.65),
635 0 0 22px rgba(124,58,237,0.75);
636 }
637 .profile-menu:focus-within .avatar {
638 transform: scale(1.05);
639 outline: 2px solid #a78bfa;
640 outline-offset: 3px;
641 }
642 /* Respect reduced-motion preferences -- still highlight the avatar
643 but drop the animated halo for users who opted out. */
644 @media (prefers-reduced-motion: reduce) {
645 .profile-menu .avatar::before { animation: none; }
646 }
647 .profile-dropdown {
648 position: absolute;
649 top: calc(100% + 10px);
650 right: 0;
651 min-width: 300px;
652 background: var(--col-surface-2);
653 border: 1px solid var(--col-border-2);
654 border-radius: 14px;
655 padding: 8px 0 6px;
656 box-shadow: 0 24px 60px rgba(0,0,0,0.55), 0 0 24px rgba(124,58,237,0.12);
657 opacity: 0; visibility: hidden;
658 transform: translateY(-6px);
659 transition: opacity 0.15s, transform 0.15s, visibility 0.15s;
660 z-index: 200;
661 /* Cap height to the viewport (minus the topbar + a small margin)
662 and scroll the contents when the menu is longer than that.
663 Admins see many items -- on a 13" laptop the menu used to clip
664 entries off-screen. */
665 max-height: calc(100vh - 90px);
666 overflow-y: auto;
667 overscroll-behavior: contain;
668 }
669 /* Slim dark scrollbar so it doesn't fight the dropdown chrome. */
670 .profile-dropdown::-webkit-scrollbar { width: 8px; }
671 .profile-dropdown::-webkit-scrollbar-track { background: transparent; }
672 .profile-dropdown::-webkit-scrollbar-thumb {
673 background: rgba(148,163,184,0.25); border-radius: 4px;
674 }
675 .profile-dropdown::-webkit-scrollbar-thumb:hover {
676 background: rgba(148,163,184,0.45);
677 }
678 /* Grace pad so cursor can move from avatar to dropdown without it
679 collapsing in the gap between them. */
680 .profile-dropdown::before {
681 content: ""; position: absolute;
682 top: -8px; left: 0; right: 0; height: 8px;
683 }
684 .profile-menu:hover .profile-dropdown,
685 .profile-menu:focus-within .profile-dropdown {
686 opacity: 1; visibility: visible; transform: translateY(0);
687 }
688 /* Header: stacked name + email + badges. No avatar (the topbar
689 trigger already shows the avatar — duplicating it here adds
690 visual noise without adding info). */
691 .profile-dropdown-header {
692 display: block;
693 padding: 14px 18px 12px;
694 }
695 .profile-dropdown-avatar { display: none; }
696 .profile-dropdown-name {
697 margin: 0 0 2px;
698 font-size: 15px; font-weight: 700;
699 color: #fff;
700 line-height: 1.3;
701 }
702 .profile-dropdown-email {
703 margin: 0;
704 font-size: 13px;
705 color: var(--col-text-3);
706 line-height: 1.35;
707 word-break: break-all;
708 }
709 .profile-dropdown-divider {
710 height: 1px;
711 background: var(--col-border);
712 margin: 4px 14px;
713 }
714 /* Section headers in muted small-caps with a divider underneath —
715 matches ShopCart / TaskForge / ABForge profile dropdown pattern. */
716 .profile-dropdown-section {
717 font-size: 10px;
718 font-weight: 700;
719 letter-spacing: 1.8px;
720 text-transform: uppercase;
721 color: var(--col-text-3);
722 padding: 14px 18px 6px;
723 margin-top: 2px;
724 border-top: 1px solid var(--col-border);
725 }
726 /* First section after the badges doesn't need its own top border. */
727 .profile-dropdown-header + .profile-dropdown-section { border-top: 1px solid var(--col-border); margin-top: 6px; }
728 .profile-dropdown a {
729 display: flex; align-items: center;
730 padding: 9px 18px;
731 font-size: 14px; font-weight: 500;
732 color: var(--col-text);
733 text-decoration: none;
734 transition: color 0.12s, background 0.12s, padding 0.12s;
735 }
736 .profile-dropdown a:hover {
737 color: var(--col-accent-bright);
738 background: rgba(124,58,237,0.10);
739 padding-left: 22px;
740 }
741 .profile-dropdown a.profile-admin { color: var(--col-accent-bright); }
742 .profile-dropdown a.profile-signout { color: #f87171; }
743 .profile-dropdown a.profile-signout:hover { color: #fca5a5; background: rgba(239,68,68,0.08); }
744 /* ShopCart-style dropdown has no icons — hide the auto-generated
745 icon spans/glyphs so items read as a clean text list. */
746 .profile-dropdown .glyph,
747 .profile-dropdown .dropdown-icon { display: none; }
748 </style>
749 $extra_css
750 <script src="/assets/js/fingerprint.js" defer></script>
751</head>
752<body>
753 <div class="app">
754 <aside class="sidebar">
755 <div class="sidebar-brand">
756 <a href="/dashboard.cgi" style="display:flex;align-items:center;gap:12px;text-decoration:none">
757 <!-- Two-letter brand mark on the purple gradient (TaskForge
758 pattern). The mark's purple bg + glow come from site.css
759 .brand-mark; here we just drop the AS initials in. -->
760 <div class="brand-mark">AS</div>
761 <div class="brand-text">
762 <span class="brand-name">$brand_name</span>
763 <span class="brand-sub">Affiliate Platform</span>
764 </div>
765 </a>
766 </div>
767 <nav class="sidebar-nav">
768 $sidebar
769 </nav>
770 <div class="sidebar-foot">
771 <div class="user-chip">
772 <div class="user-avatar">$initials</div>
773 <div class="user-meta">
774 <div class="user-name">$display_name</div>
775 <div class="user-tier">$plan_tier Plan</div>
776 </div>
777 </div>
778 <a href="/logout.cgi" class="icon-btn" title="Sign out" aria-label="Sign out">
779 <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>
780 </a>
781 </div>
782 </aside>
783
784 <div class="app-main">
785 $impersonation_banner
786 <header class="topbar">
787 <div class="topbar-left">
788 <button type="button" class="sidebar-toggle-btn" id="sidebarToggle" title="Collapse / expand sidebar" aria-label="Toggle sidebar">
789 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
790 </button>
791 <div class="topbar-title">$title</div>
792 </div>
793 <div class="topbar-tools">
794 <form class="search-box" method="GET" action="/search.cgi" role="search">
795 <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>
796 <input type="text" name="q" placeholder="Search models, orders, settings..." autocomplete="off">
797 </form>
798 [if:$is_admin]
799 <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">
800 <span class="cw-ping" aria-hidden="true"></span>
801 <span>Chat</span>
802 <span class="cw-badge[if:!$has_admin_chats_pending] cw-badge-empty[/if]"><span class="cw-count">$admin_chats_pending</span></span>
803 </a>
804 [/if]
805 <a id="msg-pill" class="msg-pill[if:$has_unread_messages] has-unread[/if]" href="$messages_href" title="Messages" aria-label="Messages">
806 <span class="msg-dot" aria-hidden="true"></span>
807 <span>Messages</span>
808 <span class="msg-badge[if:!$has_unread_messages] msg-badge-empty[/if]" data-msg-count>$unread_messages_count</span>
809 </a>
810 <!-- Help-menu: the topbar Help button is the hover anchor for
811 a dropdown that surfaces every help destination. Clicking
812 the button itself still goes to /help.cgi (the resource
813 hub) as a fallback for keyboard / no-hover usage. -->
814 <div class="help-menu" tabindex="0">
815 <a class="help-btn" href="/help.cgi" title="Open the help &amp; resources guide" aria-label="Help" aria-haspopup="menu">
816 <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>
817 <span>Help</span>
818 <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>
819 </a>
820 <div class="help-dropdown" role="menu">
821 <div class="help-dropdown-section">Help</div>
822 <a href="/help.cgi" role="menuitem">
823 <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>
824 Help center
825 </a>
826 <a href="/tutorials.cgi" role="menuitem">
827 <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>
828 Tutorials
829 </a>
830 <a href="/tutorials.cgi#section-optimization" role="menuitem">
831 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
832 A/B &amp; MVT tutorials
833 </a>
834 <a href="/tour.cgi" role="menuitem">
835 <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>
836 Product tour
837 </a>
838 </div>
839 </div>
840
841 <!-- Profile menu. Hover the avatar/name to reveal the dropdown.
842 The Admin Console entry is gated on $is_admin so members
843 never see it. Inline name+subtitle pattern ported from
844 ShopCart 2026-06-08. -->
845 <div class="profile-menu" tabindex="0">
846 <div class="profile-trigger" title="My account">
847 <div class="avatar">$initials</div>
848 <div class="profile-trigger-meta">
849 <div class="profile-trigger-name">$display_name[if:$is_admin] <span class="profile-trigger-role">(Super Admin)</span>[/if]</div>
850 <div class="profile-trigger-sub">$plan_tier Plan</div>
851 </div>
852 <svg class="profile-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>
853 </div>
854 <div class="profile-dropdown" role="menu">
855 <div class="profile-dropdown-header">
856 <div class="profile-dropdown-avatar">$initials</div>
857 <div style="min-width:0">
858 <p class="profile-dropdown-name">$display_name</p>
859 <p class="profile-dropdown-email">$email</p>
860 <div class="profile-dropdown-badges">
861 [if:$is_admin]<span class="profile-badge profile-badge-violet">Super Admin</span>[/if]
862 <span class="profile-badge profile-badge-neutral">$plan_tier</span>
863 </div>
864 </div>
865 </div>
866 <!-- Sections + items auto-generated from @SIDEBAR in
867 MODS::AffSoft::Wrapper. Never hand-edit these links --
868 they MUST stay in lockstep with the left sidebar.
869 To add / rename / remove an item, change @SIDEBAR. -->
870 $dropdown_links
871 <div class="profile-dropdown-divider"></div>
872 <a href="/logout.cgi" class="profile-signout"><span class="glyph">&times;</span> Sign Out</a>
873 </div>
874 </div>
875 </div>
876 </header>
877
878 <main class="app-content">
879 $body
880 </main>
881 </div>
882 </div>
883
884 <script src="$assets_js/site.js?v=4"></script>
885 <!-- In-app confirm modal -- replaces native window.confirm() across
886 the dashboard. Auto-binds any form/link/button with
887 data-confirm-title attrs and exposes window.appConfirm() for
888 imperative calls. Loads on every authenticated page so popups
889 feel consistent. -->
890 <script src="$assets_js/confirm.js?v=1"></script>
891 <!-- track.js (visitor tracking + chat widget) is intentionally NOT
892 loaded in the dashboard wrapper. The admin is the one running
893 chat from /admin_chat.cgi -- they should not see the bubble on
894 their own admin pages, and their own dashboard sessions should
895 not pollute the visitor analytics. track.js is loaded by
896 affsoft_marketing.html for customer-facing pages. -->
897
898 <script>
899 // Sidebar collapse toggle. The per-user preference is stored in
900 // user_settings.sidebar_collapsed -- Wrapper.pm renders the
901 // current value as a class on <html>, so the next page load is
902 // already in the right state on first paint. We POST the new
903 // value with keepalive:true so the save survives the user
904 // clicking a nav link right after toggling.
905 (function () {
906 var btn = document.getElementById('sidebarToggle')
907 || document.querySelector('.sidebar-toggle-btn, .topbar-hamburger, .sidebar-toggle');
908 if (!btn) return;
909 var html = document.documentElement;
910 // Matches the mobile breakpoint in site.css. Kept in one place via
911 // matchMedia so a future width change only updates the CSS.
912 var mqMobile = window.matchMedia('(max-width: 1200px)');
913
914 // Tiny helper: persist a single sidebar field. Server endpoint
915 // updates only the field present in the POST so a desktop
916 // toggle never clobbers the mobile state and vice versa.
917 function saveSidebar(field, val) {
918 try {
919 fetch('/save_sidebar.cgi', {
920 method: 'POST',
921 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
922 body: field + '=' + (val ? '1' : '0'),
923 keepalive: true,
924 credentials: 'same-origin'
925 });
926 } catch (e2) {}
927 }
928
929 btn.addEventListener('click', function (e) {
930 if (mqMobile.matches) {
931 // On mobile the toggle controls the slide-in overlay.
932 // Stored under sidebar_mobile_open so the next page load
933 // boots in the same drawer state on the same phone.
934 var open = html.classList.toggle('mobile-nav-open');
935 saveSidebar('sidebar_mobile_open', open);
936 e.stopPropagation();
937 return;
938 }
939 var collapsed = html.classList.toggle('sidebar-collapsed');
940 saveSidebar('sidebar_collapsed', collapsed);
941 });
942
943 // Close the mobile overlay when the user taps outside the sidebar
944 // (e.g. the dim backdrop) or follows a nav link inside it. Persist
945 // the close so the next page load doesn't reopen it.
946 document.addEventListener('click', function (e) {
947 if (!html.classList.contains('mobile-nav-open')) return;
948 if (e.target.closest('.sidebar-toggle-btn, .topbar-hamburger, .sidebar-toggle')) return; // handled above
949 if (e.target.closest('.sidebar a')) { // nav link tapped
950 html.classList.remove('mobile-nav-open');
951 saveSidebar('sidebar_mobile_open', 0);
952 return;
953 }
954 if (!e.target.closest('.sidebar')) { // backdrop tap
955 html.classList.remove('mobile-nav-open');
956 saveSidebar('sidebar_mobile_open', 0);
957 }
958 });
959
960 // If the viewport crosses back to desktop (rotation, fold open,
961 // window resize), drop the mobile-open state so we don't strand
962 // it stuck after the slide-in styles stop applying.
963 mqMobile.addEventListener('change', function (ev) {
964 if (!ev.matches) html.classList.remove('mobile-nav-open');
965 });
966 })();
967
968 // Live-poll the topbar chat pill so admins see state change
969 // WITHOUT having to refresh the page. 20s cadence keeps it cheap;
970 // the endpoint is a single indexed COUNT(). Two states:
971 // - idle (blue, "Chat", links to /admin_chat.cgi)
972 // - active (green, "N waiting", links straight to oldest chat)
973 // The pill bumps on every count increase so a brand-new chat is
974 // impossible to miss; a decreasing count never re-alerts.
975 (function () {
976 var pill = document.getElementById('chat-waiting-pill');
977 if (!pill) return;
978 var countEl = pill.querySelector('.cw-count');
979 var badgeEl = pill.querySelector('.cw-badge');
980 var POLL_MS = 20000;
981 var lastN = parseInt(countEl && countEl.textContent, 10) || 0;
982 var bumpTimer;
983 function paint(n, firstId) {
984 n = (typeof n === 'number' && n >= 0) ? n : 0;
985 firstId = (typeof firstId === 'number' && firstId > 0) ? firstId : 0;
986 if (n <= 0) {
987 // Idle: blue pill, no badge, link to chat console.
988 pill.classList.remove('cw-active');
989 pill.classList.remove('cw-bump');
990 pill.classList.add('cw-idle');
991 pill.setAttribute('href', '/admin_chat.cgi');
992 pill.setAttribute('title', 'Live chat');
993 if (badgeEl) badgeEl.classList.add('cw-badge-empty');
994 lastN = 0;
995 return;
996 }
997 // Active: green pulsing pill with red count badge.
998 if (n !== lastN && countEl) countEl.textContent = n;
999 if (badgeEl) badgeEl.classList.remove('cw-badge-empty');
1000 pill.classList.remove('cw-idle');
1001 pill.classList.add('cw-active');
1002 pill.setAttribute(
1003 'href',
1004 firstId ? ('/admin_chat.cgi?chat=' + firstId) : '/admin_chat.cgi?view=active'
1005 );
1006 pill.setAttribute('title', n + (n === 1 ? ' visitor' : ' visitors') + ' waiting for a reply');
1007 // Bump only when the count INCREASED -- a decreasing count
1008 // (admin replied, chat closed) shouldn't visually re-alert.
1009 if (n > lastN) {
1010 pill.classList.remove('cw-bump');
1011 void pill.offsetWidth; // restart animation
1012 pill.classList.add('cw-bump');
1013 clearTimeout(bumpTimer);
1014 bumpTimer = setTimeout(function () {
1015 pill.classList.remove('cw-bump');
1016 }, 500);
1017 }
1018 lastN = n;
1019 }
1020 function tick() {
1021 fetch('/admin_chat_pending.cgi', {
1022 credentials: 'same-origin',
1023 cache: 'no-store'
1024 })
1025 .then(function (r) { return r.ok ? r.json() : null; })
1026 .then(function (d) {
1027 if (d && typeof d.n === 'number') paint(d.n, d.first_id || 0);
1028 })
1029 .catch(function () { /* offline / network blip -- next tick retries */ });
1030 }
1031 setInterval(tick, POLL_MS);
1032 // Also tick once when the tab regains focus so coming back to
1033 // a backgrounded tab gives an immediate fresh count.
1034 window.addEventListener('focus', tick);
1035
1036 // JS-driven blink while active. CSS @keyframes can be silenced
1037 // by OS-level prefers-reduced-motion / some VM browsers; this
1038 // toggle is a direct DOM class change so it always runs. The
1039 // CSS rule attaches a 0.45s opacity transition so the toggle
1040 // reads as a smooth fade, not a hard flicker. Same tick also
1041 // The chat pill is admin-only. The Messages pill polling +
1042 // blink lives in its OWN IIFE below so non-admin users (who
1043 // don't have the chat pill, and would early-return out of
1044 // this whole block) still get a live-updating Messages pill.
1045 setInterval(function () {
1046 if (pill.classList.contains('cw-active')) {
1047 pill.classList.toggle('cw-blink-off');
1048 } else {
1049 pill.classList.remove('cw-blink-off');
1050 }
1051 }, 900);
1052 })();
1053
1054 // ===== Messages pill: live-poll + blink (admin AND non-admin) =====
1055 // Independent IIFE so non-admins (who don't have the chat pill
1056 // above) still get the Messages pill blinking and live-updating.
1057 (function () {
1058 var msgPill = document.getElementById('msg-pill');
1059 if (!msgPill) return;
1060 var msgBadge = msgPill.querySelector('[data-msg-count]');
1061 // Synced blink tick at 900ms (matches chat pill cadence so
1062 // when both are visible they breathe together).
1063 setInterval(function () {
1064 if (msgPill.classList.contains('has-unread')) {
1065 msgPill.classList.toggle('msg-blink-off');
1066 } else {
1067 msgPill.classList.remove('msg-blink-off');
1068 }
1069 }, 900);
1070 function msgTick() {
1071 fetch('/messages_pending.cgi', { credentials:'same-origin', cache:'no-store' })
1072 .then(function(r){ return r.ok ? r.json() : null; })
1073 .then(function(d){
1074 if (!d || typeof d.n !== 'number') return;
1075 var n = d.n;
1076 if (msgBadge) msgBadge.textContent = n;
1077 if (n > 0) {
1078 msgPill.classList.add('has-unread');
1079 if (msgBadge) msgBadge.classList.remove('msg-badge-empty');
1080 } else {
1081 msgPill.classList.remove('has-unread');
1082 msgPill.classList.remove('msg-blink-off');
1083 if (msgBadge) msgBadge.classList.add('msg-badge-empty');
1084 }
1085 })
1086 .catch(function(){});
1087 }
1088 setInterval(msgTick, 20000);
1089 window.addEventListener('focus', msgTick);
1090 })();
1091
1092 // Preserve sidebar scroll position across page reloads. Some
1093 // pages (Traffic Statistics, Visitors, etc.) auto-refresh every
1094 // 15s; without this, the menu snaps back to the top mid-click
1095 // and the user has to re-scroll. Saved per browser tab so
1096 // different tabs can scroll independently.
1097 (function () {
1098 var nav = document.querySelector('.sidebar-nav');
1099 if (!nav) return;
1100 var KEY = 'affsoft_sidebar_scroll';
1101 var saved = sessionStorage.getItem(KEY);
1102 if (saved !== null) {
1103 var n = parseInt(saved, 10);
1104 if (!isNaN(n) && n >= 0) nav.scrollTop = n;
1105 }
1106 var pending;
1107 nav.addEventListener('scroll', function () {
1108 clearTimeout(pending);
1109 pending = setTimeout(function () {
1110 try { sessionStorage.setItem(KEY, nav.scrollTop); } catch (e) {}
1111 }, 120);
1112 }, { passive: true });
1113 // Also save right before unload so a fast click-then-navigate
1114 // captures the latest position even if the debounced save
1115 // hadn't fired yet.
1116 window.addEventListener('beforeunload', function () {
1117 try { sessionStorage.setItem(KEY, nav.scrollTop); } catch (e) {}
1118 });
1119 })();
1120 </script>
1121 $extra_js
1122 <script>
1123 // Timezone pingback. Reads the browser's IANA timezone via the
1124 // Intl API and POSTs it to /_set_timezone.cgi once per session.
1125 // The endpoint updates users.timezone so every reporting query
1126 // returns values in the viewer's local time (MariaDB SET time_zone
1127 // applies via MODS::DBConnect on every fresh connection).
1128 (function(){
1129 try {
1130 var tz = (Intl && Intl.DateTimeFormat) ? Intl.DateTimeFormat().resolvedOptions().timeZone : '';
1131 if (!tz) return;
1132 var sent = '';
1133 try { sent = sessionStorage.getItem('affsoft_tz_sent'); } catch(e){}
1134 if (sent === tz) return;
1135 var fd = new FormData();
1136 fd.append('tz', tz);
1137 fetch('/_set_timezone.cgi', { method: 'POST', body: fd, credentials: 'same-origin' })
1138 .then(function(){ try { sessionStorage.setItem('affsoft_tz_sent', tz); } catch(e){} })
1139 .catch(function(){});
1140 } catch(e) {}
1141 })();
1142 </script>
1143<!-- Viewer-local timestamp renderer (portfolio-canonical).
1144 Server emits `<span class="ts" data-ts="EPOCH" data-fmt="…">FALLBACK</span>`.
1145 Formats: datetime | date | date-short | time | relative
1146 Original text is the no-JS fallback. Re-run via window.tzLocalize() after AJAX. -->
1147<script>
1148(function(){
1149 function fmt(el){
1150 var e = parseInt(el.getAttribute('data-ts'), 10);
1151 if (!e) return;
1152 var d = new Date(e * 1000);
1153 var kind = el.getAttribute('data-fmt') || 'datetime';
1154 var out;
1155 switch (kind) {
1156 case 'date': out = d.toLocaleDateString(undefined, {year:'numeric',month:'short',day:'numeric'}); break;
1157 case 'date-short': out = d.toLocaleDateString(undefined, {month:'short',day:'numeric'}); break;
1158 case 'time': out = d.toLocaleTimeString(undefined, {hour:'2-digit',minute:'2-digit'}); break;
1159 case 'relative': out = rel(e); break;
1160 default: out = d.toLocaleDateString(undefined, {year:'numeric',month:'short',day:'numeric'})
1161 + ' ' + d.toLocaleTimeString(undefined, {hour:'2-digit',minute:'2-digit'});
1162 }
1163 el.textContent = out;
1164 if (!el.title) el.title = d.toString();
1165 }
1166 function rel(e){
1167 var s = Math.floor(Date.now()/1000) - e, future = s < 0; s = Math.abs(s);
1168 var n, u;
1169 if (s < 60) { n = s; u = 'second'; }
1170 else if (s < 3600) { n = Math.floor(s/60); u = 'minute'; }
1171 else if (s < 86400) { n = Math.floor(s/3600); u = 'hour'; }
1172 else if (s < 2592000){ n = Math.floor(s/86400); u = 'day'; }
1173 else if (s < 31536000){n = Math.floor(s/2592000); u = 'month'; }
1174 else { n = Math.floor(s/31536000);u = 'year'; }
1175 var p = n === 1 ? '' : 's';
1176 return future ? ('in ' + n + ' ' + u + p) : (n + ' ' + u + p + ' ago');
1177 }
1178 function localize(root){ (root||document).querySelectorAll('span.ts[data-ts], time[data-ts]').forEach(fmt); }
1179 window.tzLocalize = localize;
1180 if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', function(){ localize(); });
1181 else localize();
1182 window.addEventListener('ts-refresh', function(){ localize(); });
1183})();
1184</script>
1185
1186</body>
1187</html>