Diff -- /var/www/vhosts/3dshawn.com/shop.3dshawn.com/TEMPLATES/shopcart_wrapper.html

O Operator
Diff

/var/www/vhosts/3dshawn.com/shop.3dshawn.com/TEMPLATES/shopcart_wrapper.html

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

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