Diff -- /var/www/vhosts/3dshawn.com/repricer.3dshawn.com/TEMPLATES/repricer_wrapper.html

O Operator
Diff

/var/www/vhosts/3dshawn.com/repricer.3dshawn.com/TEMPLATES/repricer_wrapper.html

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

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