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
+0
lines
Removed
-0
lines
Context
1145
unchanged
Blobs
from 2cf2ebe460a2
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.
11<!DOCTYPE html>
22<html lang="en" class="$html_class">
33<head>
44 <meta charset="UTF-8">
55 <meta name="viewport" content="width=device-width, initial-scale=1.0">
66 <!-- Anti-cache: belt-and-suspenders to the Cache-Control HTTP headers
77 the CGIs already emit. Some browser back/forward caches (bfcache)
88 still serve stale HTML even with no-cache headers; these meta tags
99 are the second line of defense. -->
1010 <meta http-equiv="Cache-Control" content="no-store, no-cache, must-revalidate, max-age=0">
1111 <meta http-equiv="Pragma" content="no-cache">
1212 <meta http-equiv="Expires" content="0">
1313 <link rel="icon" type="image/svg+xml" href="/assets/images/favicon.svg">
1414 <title>$title &middot; $website_title</title>
1515$meta_tags
1616 <link rel="preconnect" href="https://fonts.googleapis.com">
1717 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
1818 <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">
1919 <link rel="stylesheet" href="$assets_css/site.css?v=2026-07-02-padfix">
2020 <style>
2121 /* Collapsible sidebar -- narrows to an icon rail at --sidebar-rail-w
2222 (48px) instead of collapsing to zero. Section labels become thin
2323 divider lines, label spans hide, signout icon hides, brand mark
2424 shrinks but stays visible, and hover tooltips show the link's
2525 title on each icon. */
2626 :root { --sidebar-rail-w: 48px; }
2727 .app { transition: grid-template-columns 0.22s ease; }
2828 .sidebar { transition: padding 0.22s ease; overflow-x: hidden; }
2929 html.sidebar-collapsed .app { grid-template-columns: var(--sidebar-rail-w) 1fr; }
3030
3131 /* When collapsed: hide text, center icons, shrink horizontal padding. */
3232 html.sidebar-collapsed .sidebar { padding-left: 0; padding-right: 0; }
3333 html.sidebar-collapsed .sidebar-brand { justify-content: center; padding-left: 4px; padding-right: 4px; }
3434 html.sidebar-collapsed .sidebar-brand .brand-mark { width: 32px; height: 32px; font-size: 13px; }
3535 html.sidebar-collapsed .brand-text,
3636 html.sidebar-collapsed .sidebar-section-label,
3737 html.sidebar-collapsed .sidebar-link span,
3838 html.sidebar-collapsed .sidebar-link .badge,
3939 html.sidebar-collapsed .user-meta,
4040 html.sidebar-collapsed .sidebar-foot .icon-btn { display: none; }
4141 html.sidebar-collapsed .sidebar-nav { padding-left: 6px; padding-right: 6px; }
4242 html.sidebar-collapsed .sidebar-link {
4343 justify-content: center;
4444 padding: 11px 0;
4545 gap: 0;
4646 }
4747 html.sidebar-collapsed .sidebar-link svg { width: 20px; height: 20px; }
4848 /* Pull the accent bar back into the rail's left gutter. */
4949 html.sidebar-collapsed .sidebar-link.active::before { left: -6px; }
5050 /* Unread dot on Messages: float as a tiny corner indicator. */
5151 html.sidebar-collapsed .sidebar-link .menu-dot {
5252 position: absolute;
5353 top: 6px; right: 6px;
5454 margin: 0;
5555 }
5656 /* Section labels become a thin brand-tinted divider line since we
5757 lose the text label that would otherwise separate the groups. */
5858 html.sidebar-collapsed .sidebar-section-label {
5959 display: block !important;
6060 visibility: hidden;
6161 height: 1px;
6262 padding: 0;
6363 margin: 12px 14px 6px;
6464 background: rgba(59,130,246,0.20);
6565 }
6666 html.sidebar-collapsed .sidebar-foot {
6767 flex-direction: column;
6868 padding: 12px 6px;
6969 gap: 8px;
7070 }
7171 html.sidebar-collapsed .sidebar-foot .user-chip { justify-content: center; }
7272 html.sidebar-collapsed .user-avatar { width: 32px; height: 32px; font-size: 12px; }
7373
7474 /* Tooltip on hover when collapsed -- shows the link's label using
7575 its title attribute, anchored to the right of the rail so it
7676 doesn't bump up against the content area. */
7777 html.sidebar-collapsed .sidebar-link { position: relative; }
7878 html.sidebar-collapsed .sidebar-link[title]:hover::after {
7979 content: attr(title);
8080 position: absolute;
8181 left: calc(100% + 14px);
8282 top: 50%;
8383 transform: translateY(-50%);
8484 padding: 6px 10px;
8585 background: var(--col-surface-3);
8686 border: 1px solid var(--col-border-2);
8787 color: var(--col-text);
8888 border-radius: 6px;
8989 font-size: 12px;
9090 font-weight: 600;
9191 white-space: nowrap;
9292 box-shadow: 0 4px 14px rgba(0,0,0,0.4);
9393 z-index: 200;
9494 pointer-events: none;
9595 }
9696
9797 /* When collapsed the content area widens -- drop the centered cap. */
9898 html.sidebar-collapsed .app-content { max-width: none; padding-left: 24px; padding-right: 24px; }
9999 html.sidebar-collapsed .topbar { padding-left: 24px; padding-right: 24px; }
100100
101101 /* Topbar mini brand. Only visible at the mobile breakpoint where
102102 the sidebar fully slides out -- on desktop the icon rail keeps
103103 the brand mark on-screen, so this duplicate is hidden. */
104104 .topbar-brand {
105105 display: none;
106106 align-items: center;
107107 gap: 10px;
108108 text-decoration: none;
109109 color: inherit;
110110 margin-right: 14px;
111111 }
112112 .topbar-brand-name {
113113 font-family: var(--font-display, inherit);
114114 font-size: 15px;
115115 font-weight: 700;
116116 color: #fff;
117117 letter-spacing: 0.3px;
118118 }
119119
120120 /* Admin chat pill -- always-present topbar control with two
121121 states: idle (blue, calm) when the queue is empty, and active
122122 (green, pulsing) when visitors are waiting. The DOM is the
123123 same in both states; JS just swaps cw-idle/cw-active classes
124124 so transitions animate cleanly when a new chat lands or gets
125125 resolved. */
126126 .chat-pill {
127127 position: relative;
128128 display: inline-flex;
129129 align-items: center;
130130 gap: 9px;
131131 padding: 0 14px;
132132 height: 36px;
133133 border-radius: 10px;
134134 font-size: 12px;
135135 font-weight: 700;
136136 letter-spacing: 0.5px;
137137 text-transform: uppercase;
138138 text-decoration: none;
139139 transition: background 0.18s, color 0.18s, transform 0.12s, box-shadow 0.18s, border-color 0.18s;
140140 }
141141 .chat-pill .cw-count { font-weight: 800; color: #fff; line-height: 1; }
142142 .chat-pill .cw-badge {
143143 display: inline-flex;
144144 align-items: center;
145145 color: #fff;
146146 font-size: 12px;
147147 font-weight: 800;
148148 line-height: 1;
149149 margin-left: 4px;
150150 }
151151 .chat-pill .cw-badge.cw-badge-empty { display: none; }
152152 .cw-ping {
153153 position: relative;
154154 width: 9px; height: 9px;
155155 border-radius: 50%;
156156 flex-shrink: 0;
157157 transition: background 0.18s, box-shadow 0.18s;
158158 }
159159 .cw-ping::before,
160160 .cw-ping::after {
161161 content: '';
162162 position: absolute;
163163 inset: 0;
164164 border-radius: 50%;
165165 opacity: 0;
166166 pointer-events: none;
167167 }
168168 /* --- Idle: blue, no animation -------------------------------- */
169169 .chat-pill.cw-idle {
170170 background: linear-gradient(135deg, rgba(59,130,246,0.18) 0%, rgba(37,99,235,0.12) 100%);
171171 border: 1px solid rgba(59,130,246,0.45);
172172 color: #93c5fd;
173173 box-shadow: 0 0 10px rgba(37,99,235,0.18);
174174 }
175175 .chat-pill.cw-idle:hover {
176176 background: linear-gradient(135deg, rgba(59,130,246,0.28) 0%, rgba(37,99,235,0.20) 100%);
177177 color: #fff;
178178 transform: translateY(-1px);
179179 box-shadow: 0 0 16px rgba(37,99,235,0.45);
180180 }
181181 .chat-pill.cw-idle:focus-visible {
182182 outline: 2px solid #60a5fa;
183183 outline-offset: 2px;
184184 }
185185 .chat-pill.cw-idle .cw-ping {
186186 background: #3b82f6;
187187 box-shadow: 0 0 6px rgba(59,130,246,0.7);
188188 }
189189
190190 /* --- Active: green, pulsing dot + count badge ----------------- */
191191 .chat-pill.cw-active {
192192 background: linear-gradient(135deg, rgba(16,185,129,0.28) 0%, rgba(16,185,129,0.16) 100%);
193193 border: 1px solid rgba(16,185,129,0.70);
194194 color: #fff;
195195 box-shadow: 0 0 18px rgba(16,185,129,0.45);
196196 transition: opacity 0.45s ease-in-out, background 0.18s, color 0.18s, transform 0.12s, box-shadow 0.18s, border-color 0.18s;
197197 }
198198 /* JS toggles this class on/off every second while active, giving
199199 a fade-in/fade-out blink that can't be killed by OS-level
200200 prefers-reduced-motion (which silences CSS @keyframes). */
201201 .chat-pill.cw-active.cw-blink-off { opacity: 0.65; }
202202 .chat-pill.cw-active:hover {
203203 background: linear-gradient(135deg, rgba(16,185,129,0.34) 0%, rgba(16,185,129,0.20) 100%);
204204 color: #fff;
205205 transform: translateY(-1px);
206206 box-shadow: 0 0 22px rgba(16,185,129,0.65);
207207 }
208208 .chat-pill.cw-active:focus-visible {
209209 outline: 2px solid #34d399;
210210 outline-offset: 2px;
211211 }
212212 .chat-pill.cw-active .cw-ping {
213213 background: #10b981;
214214 box-shadow: 0 0 8px rgba(16,185,129,0.9);
215215 }
216216 .chat-pill.cw-active .cw-ping::before,
217217 .chat-pill.cw-active .cw-ping::after {
218218 background: #10b981;
219219 animation: cw-ping 1.8s cubic-bezier(0.0, 0.0, 0.2, 1) infinite;
220220 }
221221 .chat-pill.cw-active .cw-ping::after { animation-delay: 0.9s; }
222222
223223 @keyframes cw-ping {
224224 0% { transform: scale(1); opacity: 0.7; }
225225 80% { opacity: 0; }
226226 100% { transform: scale(3.6); opacity: 0; }
227227 }
228228 /* Whole-box blink: matches what the pill looks like on hover at
229229 its peak, then settles back to idle-active. Animates background,
230230 border, AND box-shadow so the entire pill feels alive instead
231231 of just a soft outer glow change. */
232232 /* Slow opacity fade -- this animates THE single most reliable
233233 CSS property (opacity has no transition competition and works
234234 on every browser), so the blink is impossible to miss. The
235235 pill fades from fully visible to ~40% and back over 2 seconds. */
236236 @keyframes cw-box-blink {
237237 0%, 100% { opacity: 1; }
238238 50% { opacity: 0.40; }
239239 }
240240 /* JS-triggered bump when the count goes up. Brief scale on top
241241 of the box blink so a new chat literally pops into view. */
242242 .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; }
243243 @keyframes cw-bump {
244244 0% { transform: scale(1); }
245245 50% { transform: scale(1.16); }
246246 100% { transform: scale(1); }
247247 }
248248
249249 /* Messages pill -- mirrors the chat-pill idle style so the three
250250 topbar controls (Chat, Messages, Help) read as one consistent
251251 row. Unread count gets a red badge inside the pill instead of
252252 the old corner dot so the number is visible at a glance. */
253253 .msg-pill {
254254 position: relative;
255255 display: inline-flex;
256256 align-items: center;
257257 gap: 9px;
258258 padding: 0 14px;
259259 height: 36px;
260260 border-radius: 10px;
261261 font-size: 12px;
262262 font-weight: 700;
263263 letter-spacing: 0.5px;
264264 text-transform: uppercase;
265265 text-decoration: none;
266266 background: linear-gradient(135deg, rgba(59,130,246,0.18) 0%, rgba(37,99,235,0.12) 100%);
267267 border: 1px solid rgba(59,130,246,0.45);
268268 color: #93c5fd;
269269 box-shadow: 0 0 10px rgba(37,99,235,0.18);
270270 transition: opacity 0.45s ease-in-out, background 0.18s, color 0.18s, transform 0.12s, box-shadow 0.18s, border-color 0.18s;
271271 }
272272 /* JS toggles this on/off every 900ms when has-unread is set,
273273 giving the same smooth fade as the active chat pill but in
274274 blue. Stays static when there's nothing unread. */
275275 .msg-pill.has-unread.msg-blink-off { opacity: 0.65; }
276276 .msg-pill .msg-badge.msg-badge-empty { display: none; }
277277 .msg-pill:hover {
278278 background: linear-gradient(135deg, rgba(59,130,246,0.28) 0%, rgba(37,99,235,0.20) 100%);
279279 color: #fff;
280280 transform: translateY(-1px);
281281 box-shadow: 0 0 16px rgba(37,99,235,0.45);
282282 }
283283 .msg-pill:focus-visible {
284284 outline: 2px solid #60a5fa;
285285 outline-offset: 2px;
286286 }
287287 .msg-pill .msg-dot {
288288 width: 9px; height: 9px;
289289 border-radius: 50%;
290290 background: #3b82f6;
291291 box-shadow: 0 0 6px rgba(59,130,246,0.7);
292292 flex-shrink: 0;
293293 }
294294 .msg-pill .msg-badge {
295295 display: inline-flex;
296296 align-items: center;
297297 justify-content: center;
298298 min-width: 18px; height: 18px;
299299 padding: 0 6px;
300300 border-radius: 9px;
301301 background: #ef4444;
302302 color: #fff;
303303 font-size: 11px;
304304 font-weight: 800;
305305 line-height: 1;
306306 box-shadow: 0 0 8px rgba(239,68,68,0.55);
307307 margin-left: 2px;
308308 }
309309
310310 /* Prominent topbar Help button. More visible than a plain icon-btn
311311 so first-time users can find docs / how-to without hunting. Blue
312312 chip with icon + label + soft glow. */
313313 .help-btn {
314314 display: inline-flex;
315315 align-items: center;
316316 gap: 7px;
317317 height: 36px;
318318 padding: 0 14px;
319319 border-radius: 10px;
320320 background: linear-gradient(135deg, rgba(59,130,246,0.22) 0%, rgba(37,99,235,0.18) 100%);
321321 border: 1px solid rgba(59,130,246,0.55);
322322 color: #93c5fd;
323323 font-size: 13px;
324324 font-weight: 700;
325325 letter-spacing: 0.3px;
326326 text-decoration: none;
327327 box-shadow: 0 0 14px rgba(37,99,235,0.32);
328328 transition: background 0.15s, color 0.15s, transform 0.12s, box-shadow 0.15s;
329329 }
330330 .help-btn svg { width: 16px; height: 16px; flex-shrink: 0; }
331331 .help-btn:hover {
332332 background: linear-gradient(135deg, rgba(59,130,246,0.32) 0%, rgba(37,99,235,0.28) 100%);
333333 color: #fff;
334334 transform: translateY(-1px);
335335 box-shadow: 0 0 18px rgba(37,99,235,0.55);
336336 }
337337 .help-btn:focus-visible {
338338 outline: 2px solid #60a5fa;
339339 outline-offset: 2px;
340340 }
341341 /* Chevron next to the Help label hints at the dropdown. Rotates
342342 slightly on hover so the open-state reads visually. */
343343 .help-btn-chevron {
344344 width: 12px !important; height: 12px !important;
345345 opacity: 0.85;
346346 transition: transform 0.15s, opacity 0.15s;
347347 }
348348 .help-menu:hover .help-btn-chevron,
349349 .help-menu:focus-within .help-btn-chevron {
350350 transform: rotate(180deg);
351351 opacity: 1;
352352 }
353353
354354 /* Help-menu dropdown -- mirrors the profile-menu pattern so the two
355355 feel like one family. Hover the chip to reveal; clicking the chip
356356 still navigates to /help.cgi for keyboard / no-hover users. */
357357 .help-menu { position: relative; }
358358 .help-dropdown {
359359 position: absolute;
360360 top: calc(100% + 8px);
361361 right: 0;
362362 min-width: 240px;
363363 background: var(--col-surface-2);
364364 border: 1px solid var(--col-border);
365365 border-radius: 12px;
366366 padding: 6px 0 8px;
367367 box-shadow: 0 20px 60px rgba(0,0,0,0.5);
368368 opacity: 0; visibility: hidden;
369369 transform: translateY(-6px);
370370 transition: opacity 0.15s, transform 0.15s, visibility 0.15s;
371371 z-index: 200;
372372 }
373373 /* Grace pad so cursor can move from button into the dropdown
374374 without crossing a gap that would collapse it. */
375375 .help-dropdown::before {
376376 content: ""; position: absolute;
377377 top: -8px; left: 0; right: 0; height: 8px;
378378 }
379379 .help-menu:hover .help-dropdown,
380380 .help-menu:focus-within .help-dropdown {
381381 opacity: 1; visibility: visible; transform: translateY(0);
382382 }
383383 .help-dropdown-section {
384384 font-size: 9px;
385385 font-weight: 800;
386386 letter-spacing: 1.5px;
387387 text-transform: uppercase;
388388 color: var(--col-text-3);
389389 padding: 10px 16px 4px;
390390 }
391391 .help-dropdown a {
392392 display: flex; align-items: center; gap: 10px;
393393 padding: 9px 16px;
394394 font-size: 13px; font-weight: 600;
395395 color: var(--col-text-2);
396396 text-decoration: none;
397397 transition: color 0.12s, background 0.12s;
398398 }
399399 .help-dropdown a svg {
400400 width: 14px; height: 14px;
401401 flex-shrink: 0;
402402 color: var(--col-text-3);
403403 }
404404 .help-dropdown a:hover,
405405 .help-dropdown a:focus-visible {
406406 color: #93c5fd;
407407 background: var(--col-surface-3);
408408 outline: none;
409409 }
410410 .help-dropdown a:hover svg,
411411 .help-dropdown a:focus-visible svg {
412412 color: #93c5fd;
413413 }
414414
415415 /* Hamburger toggle at the left of the topbar. Bare white icon by
416416 default (no fill, no border) -- on hover the dark-purple chip
417417 fades in with a thin white border so the affordance only shows
418418 when the cursor lands on it. */
419419 .sidebar-toggle {
420420 flex-shrink: 0;
421421 width: 38px; height: 38px;
422422 background: transparent;
423423 border: 1px solid transparent;
424424 border-radius: 10px;
425425 color: #fff;
426426 display: inline-flex; align-items: center; justify-content: center;
427427 cursor: pointer;
428428 margin-right: 16px;
429429 box-shadow: none;
430430 transition: background 0.15s, border-color 0.15s, box-shadow 0.15s, transform 0.12s;
431431 }
432432 .sidebar-toggle:hover {
433433 border-color: var(--col-violet);
434434 }
435435 .sidebar-toggle:active { transform: translateY(1px); }
436436 .sidebar-toggle:focus-visible {
437437 outline: 2px solid #60a5fa;
438438 outline-offset: 2px;
439439 }
440440 .sidebar-toggle svg { width: 18px; height: 18px; }
441441 /* Cluster the hamburger + page title on the left of the topbar
442442 so .topbar-tools is pushed to the far right (override the
443443 base .topbar `justify-content: space-between` distribution). */
444444 .topbar-title { margin-right: auto; }
445445
446446 /* Impersonation banner -- only rendered while an admin is acting
447447 as another user. Bright accent so it's impossible to miss. */
448448 .impersonation-banner {
449449 display: flex; align-items: center; justify-content: space-between;
450450 gap: 14px; flex-wrap: wrap;
451451 padding: 10px 18px;
452452 background: linear-gradient(90deg, rgba(245,158,11,0.20), rgba(239,68,68,0.20));
453453 border-bottom: 1px solid rgba(245,158,11,0.45);
454454 color: #fde68a; font-size: 13px;
455455 }
456456 .impersonation-banner-msg { display:flex; align-items:center; gap:10px; }
457457 .impersonation-banner-msg svg { flex-shrink: 0; }
458458 .impersonation-banner-msg strong { color: #fff; }
459459 .impersonation-banner-form { margin: 0; }
460460 .impersonation-banner-stop {
461461 background: rgba(239,68,68,0.85);
462462 border: 1px solid rgba(239,68,68,1);
463463 color: #fff; font-weight: 700;
464464 padding: 6px 14px; border-radius: 8px;
465465 font-size: 12px; cursor: pointer;
466466 letter-spacing: 0.5px;
467467 }
468468 .impersonation-banner-stop:hover { background: #dc2626; }
469469
470470 /* Top-right profile menu. Hover over the avatar to reveal the
471471 dropdown; uses CSS-only hover with a small grace area so the
472472 cursor can move from the avatar into the menu without it
473473 collapsing. */
474474 .profile-menu { position: relative; flex-shrink: 0; }
475475 /* Eye-catching avatar -- vibrant gradient + animated halo so first-
476476 time users naturally hover to see what's behind it. The halo is
477477 a soft repeating glow at low opacity that never gets noisy. */
478478 .profile-menu .avatar {
479479 position: relative;
480480 width: 38px; height: 38px;
481481 border-radius: 50%;
482482 background: linear-gradient(135deg, #3b82f6 0%, #6366f1 45%, #06b6d4 100%);
483483 display: flex; align-items: center; justify-content: center;
484484 font-weight: 800; font-size: 14px; color: #fff;
485485 cursor: pointer;
486486 letter-spacing: 0.5px;
487487 user-select: none;
488488 box-shadow:
489489 0 0 0 2px rgba(59,130,246,0.45),
490490 0 0 14px rgba(99,102,241,0.55);
491491 transition: transform 0.18s ease, box-shadow 0.18s ease;
492492 }
493493 .profile-menu .avatar::before {
494494 content: '';
495495 position: absolute;
496496 inset: -6px;
497497 border-radius: 50%;
498498 background: radial-gradient(circle, rgba(99,102,241,0.45) 0%, rgba(59,130,246,0.0) 70%);
499499 animation: avatar-pulse 2.6s ease-in-out infinite;
500500 pointer-events: none;
501501 z-index: -1;
502502 }
503503 @keyframes avatar-pulse {
504504 0%, 100% { transform: scale(1); opacity: 0.55; }
505505 50% { transform: scale(1.25); opacity: 0.95; }
506506 }
507507 .profile-menu .avatar:hover {
508508 transform: scale(1.08);
509509 box-shadow:
510510 0 0 0 3px rgba(99,102,241,0.65),
511511 0 0 22px rgba(59,130,246,0.75);
512512 }
513513 .profile-menu:focus-within .avatar {
514514 transform: scale(1.05);
515515 outline: 2px solid #60a5fa;
516516 outline-offset: 3px;
517517 }
518518 /* Respect reduced-motion preferences -- still highlight the avatar
519519 but drop the animated halo for users who opted out. */
520520 @media (prefers-reduced-motion: reduce) {
521521 .profile-menu .avatar::before { animation: none; }
522522 }
523523
524524 /* Inline trigger (avatar + name + subtitle + chevron). Whole row
525525 is the hover target -- hovering anywhere on the trigger opens
526526 the dropdown. */
527527 .profile-trigger {
528528 display: flex;
529529 align-items: center;
530530 gap: 12px;
531531 padding: 4px 10px 4px 4px;
532532 border-radius: 14px;
533533 cursor: pointer;
534534 user-select: none;
535535 transition: background 0.18s, box-shadow 0.18s;
536536 }
537537 .profile-trigger:hover {
538538 background: rgba(59,130,246,0.10);
539539 box-shadow: inset 0 0 0 1px rgba(59,130,246,0.25);
540540 }
541541 .profile-menu:focus-within .profile-trigger {
542542 background: rgba(59,130,246,0.14);
543543 box-shadow: inset 0 0 0 1px rgba(59,130,246,0.45);
544544 }
545545 .profile-trigger-meta {
546546 display: flex; flex-direction: column;
547547 min-width: 0;
548548 line-height: 1.15;
549549 max-width: 200px;
550550 }
551551 .profile-trigger-name {
552552 font-size: 13px;
553553 font-weight: 700;
554554 color: #fff;
555555 white-space: nowrap;
556556 overflow: hidden;
557557 text-overflow: ellipsis;
558558 }
559559 .profile-trigger-role {
560560 font-weight: 500;
561561 color: var(--col-accent-bright);
562562 opacity: 0.85;
563563 }
564564 .profile-trigger-sub {
565565 margin-top: 2px;
566566 font-size: 11px;
567567 font-weight: 500;
568568 letter-spacing: 0.3px;
569569 color: var(--col-text-3);
570570 white-space: nowrap;
571571 overflow: hidden;
572572 text-overflow: ellipsis;
573573 }
574574 .profile-chevron {
575575 width: 14px; height: 14px;
576576 color: var(--col-text-3);
577577 flex-shrink: 0;
578578 transition: transform 0.18s ease, color 0.18s;
579579 }
580580 .profile-menu:hover .profile-chevron,
581581 .profile-menu:focus-within .profile-chevron {
582582 transform: rotate(180deg);
583583 color: var(--col-accent-bright);
584584 }
585585 /* Phones: drop the name+chevron -- only the avatar stays interactive. */
586586 @media (max-width: 720px) {
587587 .profile-trigger-meta, .profile-chevron { display: none; }
588588 .profile-trigger { padding: 0; gap: 0; }
589589 .profile-trigger:hover, .profile-menu:focus-within .profile-trigger {
590590 background: transparent; box-shadow: none;
591591 }
592592 }
593593
594594 /* Header badges (role + plan tier) -- two outlined pills. Role gets
595595 the brand-blue outline; plan gets a neutral outline. */
596596 .profile-dropdown-badges {
597597 display: flex; flex-wrap: wrap; gap: 6px;
598598 margin-top: 8px;
599599 }
600600 .profile-badge {
601601 display: inline-flex; align-items: center;
602602 padding: 3px 9px;
603603 border-radius: 999px;
604604 font-size: 10px;
605605 font-weight: 700;
606606 letter-spacing: 0.4px;
607607 line-height: 1.4;
608608 white-space: nowrap;
609609 }
610610 .profile-badge-violet {
611611 background: transparent;
612612 color: var(--col-accent-bright);
613613 border: 1px solid var(--col-accent);
614614 }
615615 .profile-badge-neutral {
616616 background: transparent;
617617 color: var(--col-text-2);
618618 border: 1px solid var(--col-border-2);
619619 }
620620
621621 .profile-dropdown {
622622 position: absolute;
623623 top: calc(100% + 10px);
624624 right: 0;
625625 min-width: 300px;
626626 background: var(--col-surface-2);
627627 border: 1px solid var(--col-border-2);
628628 border-radius: 14px;
629629 padding: 8px 0 6px;
630630 box-shadow: 0 24px 60px rgba(0,0,0,0.55), 0 0 24px rgba(59,130,246,0.12);
631631 opacity: 0; visibility: hidden;
632632 transform: translateY(-6px);
633633 transition: opacity 0.15s, transform 0.15s, visibility 0.15s;
634634 z-index: 200;
635635 /* Cap height to the viewport (minus the topbar + a small margin)
636636 and scroll the contents when the menu is longer than that. */
637637 max-height: calc(100vh - 90px);
638638 overflow-y: auto;
639639 overscroll-behavior: contain;
640640 }
641641 /* Slim dark scrollbar so it doesn't fight the dropdown chrome. */
642642 .profile-dropdown::-webkit-scrollbar { width: 8px; }
643643 .profile-dropdown::-webkit-scrollbar-track { background: transparent; }
644644 .profile-dropdown::-webkit-scrollbar-thumb {
645645 background: rgba(148,163,184,0.25); border-radius: 4px;
646646 }
647647 .profile-dropdown::-webkit-scrollbar-thumb:hover {
648648 background: rgba(148,163,184,0.45);
649649 }
650650 /* Grace pad so cursor can move from avatar to dropdown without it
651651 collapsing in the gap between them. */
652652 .profile-dropdown::before {
653653 content: ""; position: absolute;
654654 top: -8px; left: 0; right: 0; height: 8px;
655655 }
656656 .profile-menu:hover .profile-dropdown,
657657 .profile-menu:focus-within .profile-dropdown {
658658 opacity: 1; visibility: visible; transform: translateY(0);
659659 }
660660 /* Header: stacked name + email + badges. No avatar -- the trigger
661661 up top already shows it; duplicating here adds noise. */
662662 .profile-dropdown-header {
663663 display: block;
664664 padding: 14px 18px 12px;
665665 }
666666 .profile-dropdown-avatar { display: none; }
667667 .profile-dropdown-name {
668668 margin: 0 0 2px;
669669 font-size: 15px; font-weight: 700;
670670 color: #fff;
671671 line-height: 1.3;
672672 }
673673 .profile-dropdown-email {
674674 margin: 0;
675675 font-size: 13px;
676676 color: var(--col-text-3);
677677 line-height: 1.35;
678678 word-break: break-all;
679679 }
680680 .profile-dropdown-divider {
681681 height: 1px;
682682 background: var(--col-border);
683683 margin: 4px 14px;
684684 }
685685 /* Section headers in muted small-caps with a divider underneath. */
686686 .profile-dropdown-section {
687687 font-size: 10px;
688688 font-weight: 700;
689689 letter-spacing: 1.8px;
690690 text-transform: uppercase;
691691 color: var(--col-text-3);
692692 padding: 14px 18px 6px;
693693 margin-top: 2px;
694694 border-top: 1px solid var(--col-border);
695695 }
696696 /* First section after the badges doesn't need its own top border. */
697697 .profile-dropdown-header + .profile-dropdown-section { border-top: 1px solid var(--col-border); margin-top: 6px; }
698698 .profile-dropdown a {
699699 display: flex; align-items: center;
700700 padding: 9px 18px;
701701 font-size: 14px; font-weight: 500;
702702 color: var(--col-text);
703703 text-decoration: none;
704704 transition: color 0.12s, background 0.12s, padding 0.12s;
705705 }
706706 .profile-dropdown a:hover {
707707 color: var(--col-accent-bright);
708708 background: rgba(59,130,246,0.10);
709709 padding-left: 22px;
710710 }
711711 .profile-dropdown a.profile-admin { color: var(--col-accent-bright); }
712712 .profile-dropdown a.profile-signout { color: #f87171; }
713713 .profile-dropdown a.profile-signout:hover { color: #fca5a5; background: rgba(239,68,68,0.08); }
714714 /* Hide per-item icon decorations -- text-only items only. */
715715 .profile-dropdown .glyph,
716716 .profile-dropdown .dropdown-icon { display: none; }
717717 </style>
718718 $extra_css
719719 <script src="/assets/js/fingerprint.js" defer></script>
720720</head>
721721<body>
722722 <div class="app">
723723 <aside class="sidebar">
724724 <div class="sidebar-brand">
725725 <a href="/dashboard.cgi" style="display:flex;align-items:center;gap:12px;text-decoration:none">
726726 <!-- Two-letter brand mark. The .brand-mark
727727 CSS already centers a single child via grid + handles
728728 the bold display font, so a plain text node is enough. -->
729729 <div class="brand-mark" style="width:38px;height:38px;flex:0 0 38px;font-size:18px"><span style="color:#67e8f9">CF</span></div>
730730 <div class="brand-text">
731731 <span class="brand-name">$brand_name</span>
732732 <span class="brand-sub" style="white-space:nowrap;color:#a8b6d1">CRM Platform</span>
733733 </div>
734734 </a>
735735 </div>
736736 <nav class="sidebar-nav">
737737 $sidebar
738738 </nav>
739739 <div class="sidebar-foot">
740740 <div class="user-chip">
741741 <div class="user-avatar">$initials</div>
742742 <div class="user-meta">
743743 <div class="user-name">$display_name</div>
744744 <div class="user-tier">$plan_tier Plan</div>
745745 </div>
746746 </div>
747747 <a href="/logout.cgi" class="icon-btn" title="Sign out" aria-label="Sign out">
748748 <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>
749749 </a>
750750 </div>
751751 </aside>
752752
753753 <div class="app-main">
754754 $impersonation_banner
755755 $trial_banner
756756 $cap_warn_banner
757757 <header class="topbar">
758758 <!-- Hamburger toggle. Lives in the topbar (instead of the
759759 old edge-tab) so the icon is always in a predictable
760760 spot and the sidebar can collapse to an icon-only rail
761761 without losing its toggle affordance. The same .sidebar-
762762 toggle class drives the JS in this wrapper. -->
763763 <button class="sidebar-toggle" type="button" aria-label="Toggle sidebar" title="Collapse / expand sidebar">
764764 <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>
765765 </button>
766766 <!-- Topbar-only mini brand. Only visible when the sidebar is
767767 slid out on mobile so the company logo stays on-screen at
768768 narrow viewports. CSS gates this on the mobile breakpoint. -->
769769 <a href="/dashboard.cgi" class="topbar-brand" aria-label="ContactForge">
770770 <div class="brand-mark" style="width:34px;height:34px;font-size:14px"><span style="color:#67e8f9">CF</span></div>
771771 <span class="topbar-brand-name">$brand_name</span>
772772 </a>
773773 <div class="topbar-title">$title</div>
774774 <div class="topbar-tools">
775775 <form class="search-box" method="GET" action="/search.cgi" role="search">
776776 <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>
777777 <input type="text" name="q" placeholder="Search contacts, deals, settings..." autocomplete="off">
778778 </form>
779779 [if:$is_admin]
780780 <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">
781781 <span class="cw-ping" aria-hidden="true"></span>
782782 <span>Chat</span>
783783 <span class="cw-badge[if:!$has_admin_chats_pending] cw-badge-empty[/if]"><span class="cw-count">$admin_chats_pending</span></span>
784784 </a>
785785 [/if]
786786 [if:$is_admin]
787787 <a id="msg-pill" class="msg-pill[if:$has_unread_messages] has-unread[/if]" href="$messages_href" title="Support inbox" aria-label="Support inbox">
788788 <span class="msg-dot" aria-hidden="true"></span>
789789 <span>Support</span>
790790 <span class="msg-badge[if:!$has_unread_messages] msg-badge-empty[/if]" data-msg-count>$unread_messages_count</span>
791791 </a>
792792 [/if]
793793 <!-- Help-menu: the topbar Help button is the hover anchor for
794794 a dropdown that surfaces every help destination. Clicking
795795 the button itself still goes to /help.cgi (the resource
796796 hub) as a fallback for keyboard / no-hover usage. -->
797797 <div class="help-menu" tabindex="0">
798798 <a class="help-btn" href="/help.cgi" title="Open the help &amp; resources guide" aria-label="Help" aria-haspopup="menu">
799799 <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>
800800 <span>Help</span>
801801 <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>
802802 </a>
803803 <div class="help-dropdown" role="menu">
804804 <div class="help-dropdown-section">Help</div>
805805 <a href="/help.cgi" role="menuitem">
806806 <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>
807807 Help center
808808 </a>
809809 <a href="/tutorials.cgi" role="menuitem">
810810 <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>
811811 Tutorials
812812 </a>
813813 <a href="/tutorials.cgi#section-optimization" role="menuitem">
814814 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
815815 A/B &amp; MVT tutorials
816816 </a>
817817 <a href="/tour.cgi" role="menuitem">
818818 <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>
819819 Product tour
820820 </a>
821821 </div>
822822 </div>
823823
824824 <!-- Profile menu. Hover the trigger (avatar+name+sub+chevron)
825825 to reveal the dropdown; a grace-pad above the dropdown
826826 keeps it open while the cursor crosses the gap. -->
827827 <div class="profile-menu" tabindex="0">
828828 <div class="profile-trigger" title="My account">
829829 <div class="avatar">$initials</div>
830830 <div class="profile-trigger-meta">
831831 <div class="profile-trigger-name">$display_name[if:$is_admin] <span class="profile-trigger-role">(Super Admin)</span>[/if]</div>
832832 <div class="profile-trigger-sub">$plan_tier Plan</div>
833833 </div>
834834 <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>
835835 </div>
836836 <div class="profile-dropdown" role="menu">
837837 <div class="profile-dropdown-header">
838838 <div class="profile-dropdown-avatar">$initials</div>
839839 <div style="min-width:0">
840840 <p class="profile-dropdown-name">$display_name</p>
841841 <p class="profile-dropdown-email">$email</p>
842842 <div class="profile-dropdown-badges">
843843 [if:$is_admin]<span class="profile-badge profile-badge-violet">Super Admin</span>[/if]
844844 <span class="profile-badge profile-badge-neutral">$plan_tier</span>
845845 </div>
846846 </div>
847847 </div>
848848 <!-- Sections + items auto-generated from @SIDEBAR in
849849 MODS::ContactForge::Wrapper. Never hand-edit these links
850850 -- they MUST stay in lockstep with the left sidebar.
851851 To add / rename / remove an item, change @SIDEBAR. -->
852852 $dropdown_links
853853 <div class="profile-dropdown-divider"></div>
854854 <a href="/logout.cgi" class="profile-signout"><span class="glyph">&times;</span> Sign Out</a>
855855 </div>
856856 </div>
857857 </div>
858858 </header>
859859
860860 <main class="app-content">
861861 $body
862862 </main>
863863 </div>
864864 </div>
865865
866866 <script src="$assets_js/site.js?v=2026-06-12-fixbrand"></script>
867867 <!-- In-app confirm modal -- replaces native window.confirm() across
868868 the dashboard. Auto-binds any form/link/button with
869869 data-confirm-title attrs and exposes window.appConfirm() for
870870 imperative calls. Loads on every authenticated page so popups
871871 feel consistent. -->
872872 <script src="$assets_js/confirm.js?v=1"></script>
873873 <!-- track.js (visitor tracking + chat widget) is intentionally NOT
874874 loaded in the dashboard wrapper. The admin is the one running
875875 chat from /admin_chat.cgi -- they should not see the bubble on
876876 their own admin pages, and their own dashboard sessions should
877877 not pollute the visitor analytics. track.js is loaded by
878878 cf_marketing.html for customer-facing pages. -->
879879
880880 <script>
881881 // Sidebar collapse toggle. The per-user preference is stored in
882882 // user_settings.sidebar_collapsed -- Wrapper.pm renders the
883883 // current value as a class on <html>, so the next page load is
884884 // already in the right state on first paint. We POST the new
885885 // value with keepalive:true so the save survives the user
886886 // clicking a nav link right after toggling.
887887 (function () {
888888 var btn = document.querySelector('.sidebar-toggle');
889889 if (!btn) return;
890890 var html = document.documentElement;
891891 // Matches the mobile breakpoint in site.css. Kept in one place via
892892 // matchMedia so a future width change only updates the CSS.
893893 var mqMobile = window.matchMedia('(max-width: 1200px)');
894894 // Tiny helper: persist a single sidebar field. Server endpoint
895895 // updates only the field present in the POST so a desktop
896896 // toggle never clobbers the mobile state and vice versa.
897897 function saveSidebar(field, val) {
898898 try {
899899 fetch('/save_sidebar.cgi', {
900900 method: 'POST',
901901 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
902902 body: field + '=' + (val ? '1' : '0'),
903903 keepalive: true,
904904 credentials: 'same-origin'
905905 });
906906 } catch (e2) {}
907907 }
908908
909909 btn.addEventListener('click', function (e) {
910910 if (mqMobile.matches) {
911911 // On mobile the toggle controls the slide-in overlay.
912912 // Stored under sidebar_mobile_open so the next page load
913913 // boots in the same drawer state on the same phone.
914914 var open = html.classList.toggle('mobile-nav-open');
915915 saveSidebar('sidebar_mobile_open', open);
916916 e.stopPropagation();
917917 return;
918918 }
919919 var collapsed = html.classList.toggle('sidebar-collapsed');
920920 saveSidebar('sidebar_collapsed', collapsed);
921921 });
922922
923923 // Close the mobile overlay when the user taps outside the sidebar
924924 // (e.g. the dim backdrop) or follows a nav link inside it.
925925 document.addEventListener('click', function (e) {
926926 if (!html.classList.contains('mobile-nav-open')) return;
927927 if (e.target.closest('.sidebar-toggle')) return; // handled above
928928 if (e.target.closest('.sidebar a')) { // nav link tapped
929929 html.classList.remove('mobile-nav-open');
930930 saveSidebar('sidebar_mobile_open', 0);
931931 return;
932932 }
933933 if (!e.target.closest('.sidebar')) { // backdrop tap
934934 html.classList.remove('mobile-nav-open');
935935 saveSidebar('sidebar_mobile_open', 0);
936936 }
937937 });
938938
939939 // If the viewport crosses back to desktop (rotation, fold open,
940940 // window resize), drop the mobile-open state so we don't strand
941941 // it stuck after the slide-in styles stop applying.
942942 mqMobile.addEventListener('change', function (ev) {
943943 if (!ev.matches) html.classList.remove('mobile-nav-open');
944944 });
945945 })();
946946
947947 // Live-poll the topbar chat pill so admins see state change
948948 // WITHOUT having to refresh the page. 20s cadence keeps it cheap;
949949 // the endpoint is a single indexed COUNT(). Two states:
950950 // - idle (blue, "Chat", links to /admin_chat.cgi)
951951 // - active (green, "N waiting", links straight to oldest chat)
952952 // The pill bumps on every count increase so a brand-new chat is
953953 // impossible to miss; a decreasing count never re-alerts.
954954 (function () {
955955 var pill = document.getElementById('chat-waiting-pill');
956956 if (!pill) return;
957957 var countEl = pill.querySelector('.cw-count');
958958 var badgeEl = pill.querySelector('.cw-badge');
959959 var POLL_MS = 20000;
960960 var lastN = parseInt(countEl && countEl.textContent, 10) || 0;
961961 var bumpTimer;
962962 function paint(n, firstId) {
963963 n = (typeof n === 'number' && n >= 0) ? n : 0;
964964 firstId = (typeof firstId === 'number' && firstId > 0) ? firstId : 0;
965965 if (n <= 0) {
966966 // Idle: blue pill, no badge, link to chat console.
967967 pill.classList.remove('cw-active');
968968 pill.classList.remove('cw-bump');
969969 pill.classList.add('cw-idle');
970970 pill.setAttribute('href', '/admin_chat.cgi');
971971 pill.setAttribute('title', 'Live chat');
972972 if (badgeEl) badgeEl.classList.add('cw-badge-empty');
973973 lastN = 0;
974974 return;
975975 }
976976 // Active: green pulsing pill with red count badge.
977977 if (n !== lastN && countEl) countEl.textContent = n;
978978 if (badgeEl) badgeEl.classList.remove('cw-badge-empty');
979979 pill.classList.remove('cw-idle');
980980 pill.classList.add('cw-active');
981981 pill.setAttribute(
982982 'href',
983983 firstId ? ('/admin_chat.cgi?chat=' + firstId) : '/admin_chat.cgi?view=active'
984984 );
985985 pill.setAttribute('title', n + (n === 1 ? ' visitor' : ' visitors') + ' waiting for a reply');
986986 // Bump only when the count INCREASED -- a decreasing count
987987 // (admin replied, chat closed) shouldn't visually re-alert.
988988 if (n > lastN) {
989989 pill.classList.remove('cw-bump');
990990 void pill.offsetWidth; // restart animation
991991 pill.classList.add('cw-bump');
992992 clearTimeout(bumpTimer);
993993 bumpTimer = setTimeout(function () {
994994 pill.classList.remove('cw-bump');
995995 }, 500);
996996 }
997997 lastN = n;
998998 }
999999 function tick() {
10001000 fetch('/admin_chat_pending.cgi', {
10011001 credentials: 'same-origin',
10021002 cache: 'no-store'
10031003 })
10041004 .then(function (r) { return r.ok ? r.json() : null; })
10051005 .then(function (d) {
10061006 if (d && typeof d.n === 'number') paint(d.n, d.first_id || 0);
10071007 })
10081008 .catch(function () { /* offline / network blip -- next tick retries */ });
10091009 }
10101010 setInterval(tick, POLL_MS);
10111011 // Also tick once when the tab regains focus so coming back to
10121012 // a backgrounded tab gives an immediate fresh count.
10131013 window.addEventListener('focus', tick);
10141014
10151015 // JS-driven blink while active. CSS @keyframes can be silenced
10161016 // by OS-level prefers-reduced-motion / some VM browsers; this
10171017 // toggle is a direct DOM class change so it always runs. The
10181018 // CSS rule attaches a 0.45s opacity transition so the toggle
10191019 // reads as a smooth fade, not a hard flicker. Same tick also
10201020 // The chat pill is admin-only. The Messages pill polling +
10211021 // blink lives in its OWN IIFE below so non-admin users (who
10221022 // don't have the chat pill, and would early-return out of
10231023 // this whole block) still get a live-updating Messages pill.
10241024 setInterval(function () {
10251025 if (pill.classList.contains('cw-active')) {
10261026 pill.classList.toggle('cw-blink-off');
10271027 } else {
10281028 pill.classList.remove('cw-blink-off');
10291029 }
10301030 }, 900);
10311031 })();
10321032
10331033 // ===== Support-inbox pill: blink-only (admin) =====
10341034 // Pill is rendered server-side admin-only; we just animate the
10351035 // unread state. Count refreshes on next page load. (Old cross-
10361036 // platform live-poll to /messages_pending.cgi was removed with
10371037 // the WebSTLs external_comments inbox.)
10381038 (function () {
10391039 var msgPill = document.getElementById('msg-pill');
10401040 if (!msgPill) return;
10411041 setInterval(function () {
10421042 if (msgPill.classList.contains('has-unread')) {
10431043 msgPill.classList.toggle('msg-blink-off');
10441044 } else {
10451045 msgPill.classList.remove('msg-blink-off');
10461046 }
10471047 }, 900);
10481048 })();
10491049
10501050 // Preserve sidebar scroll position across page reloads. Some
10511051 // pages (Traffic Statistics, Visitors, etc.) auto-refresh every
10521052 // 15s; without this, the menu snaps back to the top mid-click
10531053 // and the user has to re-scroll. Saved per browser tab so
10541054 // different tabs can scroll independently.
10551055 (function () {
10561056 var nav = document.querySelector('.sidebar-nav');
10571057 if (!nav) return;
10581058 var KEY = 'contactforge_sidebar_scroll';
10591059 var saved = sessionStorage.getItem(KEY);
10601060 if (saved !== null) {
10611061 var n = parseInt(saved, 10);
10621062 if (!isNaN(n) && n >= 0) nav.scrollTop = n;
10631063 }
10641064 var pending;
10651065 nav.addEventListener('scroll', function () {
10661066 clearTimeout(pending);
10671067 pending = setTimeout(function () {
10681068 try { sessionStorage.setItem(KEY, nav.scrollTop); } catch (e) {}
10691069 }, 120);
10701070 }, { passive: true });
10711071 // Also save right before unload so a fast click-then-navigate
10721072 // captures the latest position even if the debounced save
10731073 // hadn't fired yet.
10741074 window.addEventListener('beforeunload', function () {
10751075 try { sessionStorage.setItem(KEY, nav.scrollTop); } catch (e) {}
10761076 });
10771077 })();
10781078 </script>
10791079 $extra_js
10801080 <script>
10811081 // Timezone pingback. Reads the browser's IANA timezone via the
10821082 // Intl API and POSTs it to /_set_timezone.cgi once per session.
10831083 // The endpoint updates users.timezone so every reporting query
10841084 // returns values in the viewer's local time (MariaDB SET time_zone
10851085 // applies via MODS::DBConnect on every fresh connection).
10861086 (function(){
10871087 try {
10881088 var tz = (Intl && Intl.DateTimeFormat) ? Intl.DateTimeFormat().resolvedOptions().timeZone : '';
10891089 if (!tz) return;
10901090 var sent = '';
10911091 try { sent = sessionStorage.getItem('contactforge_tz_sent'); } catch(e){}
10921092 if (sent === tz) return;
10931093 var fd = new FormData();
10941094 fd.append('tz', tz);
10951095 fetch('/_set_timezone.cgi', { method: 'POST', body: fd, credentials: 'same-origin' })
10961096 .then(function(){ try { sessionStorage.setItem('contactforge_tz_sent', tz); } catch(e){} })
10971097 .catch(function(){});
10981098 } catch(e) {}
10991099 })();
11001100 </script>
11011101<!-- Viewer-local timestamp renderer (portfolio-canonical).
11021102 Server emits `<span class="ts" data-ts="EPOCH" data-fmt="…">FALLBACK</span>`.
11031103 Formats: datetime | date | date-short | time | relative
11041104 Original text is the no-JS fallback. Re-run via window.tzLocalize() after AJAX. -->
11051105<script>
11061106(function(){
11071107 function fmt(el){
11081108 var e = parseInt(el.getAttribute('data-ts'), 10);
11091109 if (!e) return;
11101110 var d = new Date(e * 1000);
11111111 var kind = el.getAttribute('data-fmt') || 'datetime';
11121112 var out;
11131113 switch (kind) {
11141114 case 'date': out = d.toLocaleDateString(undefined, {year:'numeric',month:'short',day:'numeric'}); break;
11151115 case 'date-short': out = d.toLocaleDateString(undefined, {month:'short',day:'numeric'}); break;
11161116 case 'time': out = d.toLocaleTimeString(undefined, {hour:'2-digit',minute:'2-digit'}); break;
11171117 case 'relative': out = rel(e); break;
11181118 default: out = d.toLocaleDateString(undefined, {year:'numeric',month:'short',day:'numeric'})
11191119 + ' ' + d.toLocaleTimeString(undefined, {hour:'2-digit',minute:'2-digit'});
11201120 }
11211121 el.textContent = out;
11221122 if (!el.title) el.title = d.toString();
11231123 }
11241124 function rel(e){
11251125 var s = Math.floor(Date.now()/1000) - e, future = s < 0; s = Math.abs(s);
11261126 var n, u;
11271127 if (s < 60) { n = s; u = 'second'; }
11281128 else if (s < 3600) { n = Math.floor(s/60); u = 'minute'; }
11291129 else if (s < 86400) { n = Math.floor(s/3600); u = 'hour'; }
11301130 else if (s < 2592000){ n = Math.floor(s/86400); u = 'day'; }
11311131 else if (s < 31536000){n = Math.floor(s/2592000); u = 'month'; }
11321132 else { n = Math.floor(s/31536000);u = 'year'; }
11331133 var p = n === 1 ? '' : 's';
11341134 return future ? ('in ' + n + ' ' + u + p) : (n + ' ' + u + p + ' ago');
11351135 }
11361136 function localize(root){ (root||document).querySelectorAll('span.ts[data-ts], time[data-ts]').forEach(fmt); }
11371137 window.tzLocalize = localize;
11381138 if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', function(){ localize(); });
11391139 else localize();
11401140 window.addEventListener('ts-refresh', function(){ localize(); });
11411141})();
11421142</script>
11431143
11441144</body>
11451145</html>