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

O Operator
Diff

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

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

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