Before Theme Change
This commit is contained in:
@@ -69,6 +69,33 @@ body {
|
||||
color: #7a1520 !important;
|
||||
}
|
||||
|
||||
/* ── NUMBER STEPPER CONTROLS ─────────────────────────────────────── */
|
||||
/* Dark mode steppers blend in naturally. Light mode needs explicit lift:
|
||||
white surfaces stand off the warm card bg; accent symbols tie to brand. */
|
||||
.step-btn {
|
||||
background: #ffffff !important;
|
||||
border-color: #b8b4ae !important; /* stronger than --border #d0cdc7 for crispness */
|
||||
color: var(--accent) !important; /* accent blue +/- symbols instead of muted grey */
|
||||
}
|
||||
.step-btn:hover {
|
||||
background: #f0ede7 !important; /* warm tint matches section card on hover */
|
||||
border-color: var(--accent) !important;
|
||||
color: var(--accent) !important;
|
||||
}
|
||||
.step-btn:active {
|
||||
background: var(--accent) !important;
|
||||
border-color: var(--accent) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
.num-input {
|
||||
background: #ffffff !important;
|
||||
border-color: #b8b4ae !important;
|
||||
}
|
||||
.num-input:focus {
|
||||
border-color: var(--accent) !important;
|
||||
box-shadow: 0 0 0 2px rgba(26, 106, 152, 0.15) !important;
|
||||
}
|
||||
|
||||
/* ── ADDON ROW SELECTED ──────────────────────────────────────────── */
|
||||
.addon-row.selected {
|
||||
background: #dff0fb !important;
|
||||
@@ -132,6 +159,12 @@ body {
|
||||
background: #eaf5ef !important;
|
||||
border-color: #a8d5b8 !important;
|
||||
}
|
||||
/* Amber warning state — JS toggles .savings-amber class */
|
||||
.savings-result.savings-amber {
|
||||
background: #fff4e0 !important;
|
||||
border-color: #e8c57a !important;
|
||||
color: var(--amber) !important;
|
||||
}
|
||||
|
||||
/* ── PITCH FOOTER (green strip) ──────────────────────────────────── */
|
||||
.pitch-footer {
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
font-family: 'DM Mono', monospace;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.07em;
|
||||
color: #555;
|
||||
color: var(--muted);
|
||||
text-align: right;
|
||||
line-height: 1.6;
|
||||
margin-left: auto;
|
||||
@@ -111,13 +111,13 @@
|
||||
display: grid;
|
||||
grid-template-columns: 3fr 2fr;
|
||||
gap: 52px;
|
||||
padding: 52px clamp(20px,2vw,40px) 52px;
|
||||
padding: 50px clamp(20px,2vw,40px) 52px;
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
align-items: start;
|
||||
}
|
||||
.main-col { display: flex; flex-direction: column; gap: 28px; }
|
||||
.side-col { position: sticky; top: 35px; z-index: 10; align-self: start; }
|
||||
.side-col { position: sticky; top: 102px; z-index: 10; align-self: start; }
|
||||
|
||||
/* ── CLIENT BAR ─────────────────────────────────────────────────
|
||||
Lives inside .main-col, above section I.
|
||||
@@ -166,7 +166,7 @@
|
||||
margin-left: 96px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
background: #272420; /* elevated above paper #22201d — was #1e1c18 (sunken) */
|
||||
background: var(--card); /* elevated above paper — was #272420 hardcoded */
|
||||
padding: 32px 36px 36px;
|
||||
}
|
||||
.main-col > .section:first-of-type { margin-top: 24px; }
|
||||
@@ -267,14 +267,11 @@
|
||||
Buttons call stepCount() which stops propagation.
|
||||
─────────────────────────────────────────────────────────────── */
|
||||
.sec-collapsed-counter {
|
||||
display: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.section:not(.sec-open) .sec-collapsed-counter {
|
||||
display: flex;
|
||||
}
|
||||
.sec-count-btn {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
@@ -321,6 +318,10 @@
|
||||
gap: 6px;
|
||||
}
|
||||
.pill-toggle label:last-child { border-right: none; }
|
||||
.pill-toggle input:focus-visible + label {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.pill-toggle input:checked + label {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
@@ -350,6 +351,10 @@
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.tier-seg-wrap input[type=radio] { display: none; }
|
||||
.tier-seg-wrap input[type=radio]:focus-visible + .tier-seg {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.tier-seg {
|
||||
padding: 16px 10px;
|
||||
cursor: pointer;
|
||||
@@ -424,6 +429,12 @@
|
||||
border-radius: 2px;
|
||||
padding: 3px 8px;
|
||||
white-space: nowrap;
|
||||
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.addon-preview-pill.active {
|
||||
color: var(--green);
|
||||
border-color: var(--green);
|
||||
background: rgba(33,112,69,0.10);
|
||||
}
|
||||
.collapsible-body {
|
||||
padding: 16px 0 20px 28px;
|
||||
@@ -459,6 +470,8 @@
|
||||
color: var(--green);
|
||||
font-size: 11px;
|
||||
}
|
||||
.byol-mode .m365-feature { text-decoration: line-through; opacity: 0.55; }
|
||||
.byol-mode .m365-feature::before { color: var(--amber); }
|
||||
|
||||
/* ── NUMBER INPUTS ──────────────────────────────────────────────
|
||||
.input-row — flex row: label left, .num-input right
|
||||
@@ -660,7 +673,6 @@
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
margin-top: 100px;
|
||||
}
|
||||
.sidebar-header {
|
||||
padding: 20px 24px;
|
||||
@@ -681,7 +693,7 @@
|
||||
color: #fff;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.sidebar-client.placeholder { color: rgba(255,255,255,0.35); font-weight: 400; font-style: italic; }
|
||||
.sidebar-client.placeholder { color: rgba(255,255,255,0.65); font-weight: 400; font-style: italic; }
|
||||
.sidebar-body { padding: 24px; }
|
||||
.sidebar-line {
|
||||
display: flex;
|
||||
@@ -763,14 +775,13 @@
|
||||
.nudge-nav-btn — SVG chevron pills (‹ ›), hidden when only 1 nudge.
|
||||
BOTH #nudgeBanner and #nudgeBanner_m are updated by renderNudge()
|
||||
via applyNudge('') and applyNudge('_m').
|
||||
nudgeBanner MUST stay inside .sidebar-body div.
|
||||
nudgeBanner sits between .sidebar-header and .sidebar-body.
|
||||
─────────────────────────────────────────────────────────────── */
|
||||
.nudge-banner {
|
||||
margin: 0;
|
||||
margin: 0 0 16px 0;
|
||||
padding: 18px 24px;
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
border-top: 1px solid var(--border);
|
||||
min-height: 130px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -945,6 +956,10 @@
|
||||
user-select: none;
|
||||
}
|
||||
.qs-toggle-row input[type=checkbox] { display: none; }
|
||||
.qs-toggle-row input[type=checkbox]:focus-visible + .qs-switch {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.qs-switch {
|
||||
width: 34px;
|
||||
height: 20px;
|
||||
@@ -1032,7 +1047,7 @@
|
||||
─────────────────────────────────────────────────────────────── */
|
||||
|
||||
/* Section I — admin fee display row */
|
||||
.admin-fee-header { display: flex; align-items: baseline; gap: 12px; margin-bottom: 6px; }
|
||||
/* .admin-fee-header base styles merged into the waived section definition at line ~1079 */
|
||||
.admin-fee-title { font-family: 'Poppins', sans-serif; font-weight: 600; font-size: 22px; }
|
||||
.admin-fee-val { font-family: 'DM Mono', monospace; font-size: 22px; color: var(--accent); }
|
||||
.admin-fee-sub { font-size: 12px; color: var(--muted); margin-bottom: 12px; }
|
||||
@@ -1078,7 +1093,7 @@
|
||||
.sl-otf-waived .otf-waived-label { text-decoration: none; font-weight: 600; letter-spacing: 0.06em; }
|
||||
|
||||
/* ── ADMIN FEE WAIVED display */
|
||||
.admin-fee-header { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; }
|
||||
.admin-fee-header { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; margin-bottom: 6px; }
|
||||
.admin-fee-waive-toggle { margin-left: auto; }
|
||||
.admin-fee-strike { text-decoration: line-through; color: var(--muted); text-decoration-color: var(--muted); }
|
||||
.admin-fee-waived-badge { font-family: 'DM Mono', monospace; font-size: 12px; font-weight: 700; letter-spacing: 0.08em; color: var(--green); background: rgba(33,112,69,0.12); border: 1px solid rgba(33,112,69,0.28); border-radius: 4px; padding: 2px 7px; vertical-align: middle; }
|
||||
@@ -1233,6 +1248,12 @@
|
||||
color: var(--green);
|
||||
line-height: 1.65;
|
||||
}
|
||||
/* Amber modifier — toggled by JS (classList.add/remove) when VoIP quote > current bill */
|
||||
.savings-result.savings-amber {
|
||||
background: #2e1f08;
|
||||
border-color: #5a3a10;
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
/* ── BOTTOM PITCH BANNER ────────────────────────────────────────
|
||||
4-column grid (2-col on tablet/mobile) outside the .outer grid.
|
||||
@@ -1697,9 +1718,9 @@
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-height: 92vh;
|
||||
max-height: 100vh;
|
||||
background: var(--card);
|
||||
border-radius: 20px 20px 0 0;
|
||||
border-radius: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
@@ -1764,12 +1785,16 @@
|
||||
box-shadow: none !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
/* Sidebar-header inside panel — hide it, panel has its own header row */
|
||||
/* Keep Live Quote header visible in responsive panel so
|
||||
Insight can sit directly below it (matching desktop order). */
|
||||
.mobile-panel-sheet .sidebar-header {
|
||||
display: none !important;
|
||||
display: block !important;
|
||||
}
|
||||
.mobile-panel-sheet .sidebar-body {
|
||||
padding-top: 4px !important;
|
||||
padding-top: 0 !important;
|
||||
}
|
||||
.mobile-panel-sheet .nudge-banner {
|
||||
margin-bottom: 35px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,17 @@
|
||||
<div class="sidebar-title">SVS MSP — Live Quote</div>
|
||||
<div class="sidebar-client" id="clientNameDisplay_m">Client Name</div>
|
||||
</div>
|
||||
<!-- Nudge Banner (mobile) -->
|
||||
<div id="nudgeBanner_m" class="nudge-banner amber hidden">
|
||||
<div class="nudge-header-row">
|
||||
<span class="nudge-banner-label" style="margin-bottom:0;"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512" width="12" height="14" fill="currentColor" style="margin-right:6px;vertical-align:middle;"><path d="M272 384c9.6-31.9 29.5-59.1 49.2-86.2l0 0c5.2-7.1 10.4-14.2 15.4-21.4c19.8-28.5 31.4-63 31.4-100.3C368 78.8 289.2 0 192 0S16 78.8 16 176c0 37.3 11.6 71.9 31.4 100.3c5 7.2 10.2 14.3 15.4 21.4l0 0c19.8 27.1 39.7 54.4 49.2 86.2H272zM192 512c44.2 0 80-35.8 80-80V416H112v16c0 44.2 35.8 80 80 80zM112 352H272c0 0 0 0 0 0H112c0 0 0 0 0 0z"/></svg> Insight <span id="nudgeCounter_m" class="nudge-counter"></span></span>
|
||||
<div class="nudge-nav-group">
|
||||
<button onclick="cycleNudge(-1)" class="nudge-nav-btn" title="Previous"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg></button>
|
||||
<button onclick="cycleNudge(1)" class="nudge-nav-btn" title="Next"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
<span id="nudgeText_m"></span>
|
||||
</div>
|
||||
<div class="sidebar-body">
|
||||
<div id="sidebarLines_m">
|
||||
<div class="sidebar-note hidden" id="sideNote-m365_m"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="14" height="14" fill="var(--green)" style="margin-right:6px;vertical-align:middle;flex-shrink:0;"><path d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM369 209L241 337c-9.4 9.4-24.6 9.4-33.9 0l-64-64c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0l47 47L335 175c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9z"/></svg> Bundled M365 saves client up to <strong id="m365SaveAmt_m" style="color:var(--green);">—</strong>/mo vs retail licensing</div>
|
||||
@@ -67,12 +78,12 @@
|
||||
<span><span class="lbl-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512" width="14" height="13" fill="currentColor" style="vertical-align:middle;"><path d="M144 0a80 80 0 1 1 0 160A80 80 0 1 1 144 0zM512 0a80 80 0 1 1 0 160A80 80 0 1 1 512 0zM0 298.7C0 239.8 47.8 192 106.7 192h42.7c15.9 0 31 3.5 44.6 9.7c-1.3 7.2-1.9 14.7-1.9 22.3c0 38.2 16.8 72.5 43.3 96c-.2 0-.4 0-.7 0H21.3C9.6 320 0 310.4 0 298.7zM405.3 320c-.2 0-.4 0-.7 0c26.6-23.5 43.3-57.8 43.3-96c0-7.6-.7-15-1.9-22.3c13.6-6.3 28.7-9.7 44.6-9.7h42.7C592.2 192 640 239.8 640 298.7c0 11.8-9.6 21.3-21.3 21.3H405.3zM224 224a96 96 0 1 1 192 0 96 96 0 1 1 -192 0zM128 485.3C128 411.7 187.7 352 261.3 352H378.7C452.3 352 512 411.7 512 485.3c0 14.7-11.9 26.7-26.7 26.7H154.7c-14.7 0-26.7-11.9-26.7-26.7z"/></svg></span> Users</span>
|
||||
<span class="val" id="sl-users-val_m">—</span>
|
||||
</div>
|
||||
<div class="sl-sub" style="display:none;" id="sl-users-sub_m"></div>
|
||||
<div class="sl-sub hidden" id="sl-users-sub_m"></div>
|
||||
<div class="sidebar-line hidden" id="sl-endpoints_m">
|
||||
<span><span class="lbl-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512" width="14" height="13" fill="currentColor" style="vertical-align:middle;"><path d="M64 0C28.7 0 0 28.7 0 64V352c0 35.3 28.7 64 64 64H240l-10.7 32H160c-17.7 0-32 14.3-32 32s14.3 32 32 32H416c17.7 0 32-14.3 32-32s-14.3-32-32-32H346.7L336 416H512c35.3 0 64-28.7 64-64V64c0-35.3-28.7-64-64-64H64zM512 64V352H64V64H512z"/></svg></span> Endpoints</span>
|
||||
<span class="val" id="sl-endpoints-val_m">—</span>
|
||||
</div>
|
||||
<div class="sl-sub" style="display:none;" id="sl-endpoints-sub_m"></div>
|
||||
<div class="sl-sub hidden" id="sl-endpoints-sub_m"></div>
|
||||
<div class="sidebar-line hidden" id="sl-servers_m">
|
||||
<span><span class="lbl-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="13" height="13" fill="currentColor" style="vertical-align:middle;"><path d="M64 32C28.7 32 0 60.7 0 96v64c0 35.3 28.7 64 64 64H448c35.3 0 64-28.7 64-64V96c0-35.3-28.7-64-64-64H64zm280 72a24 24 0 1 1 0 48 24 24 0 1 1 0-48zm48 24a24 24 0 1 1 48 0 24 24 0 1 1 -48 0zM64 288c-35.3 0-64 28.7-64 64v64c0 35.3 28.7 64 64 64H448c35.3 0 64-28.7 64-64V352c0-35.3-28.7-64-64-64H64zm280 72a24 24 0 1 1 0 48 24 24 0 1 1 0-48zm56 24a24 24 0 1 1 48 0 24 24 0 1 1 -48 0z"/></svg></span> Servers</span>
|
||||
<span class="val" id="sl-servers-val_m">—</span>
|
||||
@@ -131,7 +142,7 @@
|
||||
<span>Annual Projection</span>
|
||||
<span class="val" id="annualDisplay_m">$1,800</span>
|
||||
</div>
|
||||
<div class="sidebar-line" id="perUserRow_m" style="display:none;">
|
||||
<div class="sidebar-line hidden" id="perUserRow_m">
|
||||
<span>Avg. Cost Per User<br><small id="perUserBreakdown_m" class="per-user-cost-sub sidebar-note-mono hidden"></small></span>
|
||||
<span class="val" id="perUserDisplay_m">—</span>
|
||||
</div>
|
||||
@@ -160,18 +171,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nudge Banner -->
|
||||
<div id="nudgeBanner_m" class="nudge-banner amber hidden">
|
||||
<div class="nudge-header-row">
|
||||
<span class="nudge-banner-label" style="margin-bottom:0;"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512" width="12" height="14" fill="currentColor" style="margin-right:6px;vertical-align:middle;"><path d="M272 384c9.6-31.9 29.5-59.1 49.2-86.2l0 0c5.2-7.1 10.4-14.2 15.4-21.4c19.8-28.5 31.4-63 31.4-100.3C368 78.8 289.2 0 192 0S16 78.8 16 176c0 37.3 11.6 71.9 31.4 100.3c5 7.2 10.2 14.3 15.4 21.4l0 0c19.8 27.1 39.7 54.4 49.2 86.2H272zM192 512c44.2 0 80-35.8 80-80V416H112v16c0 44.2 35.8 80 80 80zM112 352H272c0 0 0 0 0 0H112c0 0 0 0 0 0z"/></svg> Insight <span id="nudgeCounter_m" class="nudge-counter"></span></span>
|
||||
<div class="nudge-nav-group">
|
||||
<button onclick="cycleNudge(-1)" class="nudge-nav-btn" title="Previous"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg></button>
|
||||
<button onclick="cycleNudge(1)" class="nudge-nav-btn" title="Next"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
<span id="nudgeText_m"></span>
|
||||
</div>
|
||||
|
||||
<div class="export-wrap">
|
||||
<button class="btn-export" onclick="printInvoice()">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="14" height="14" fill="currentColor" style="margin-right:7px;vertical-align:middle;"><path d="M128 0C92.7 0 64 28.7 64 64v96h64V64H354.7L384 93.3V160h64V93.3c0-17-6.7-33.3-18.7-45.3L400 18.7C388 6.7 371.7 0 354.7 0H128zM384 352v32 64H128V384 352H384zm64 32h32c17.7 0 32-14.3 32-32V256c0-35.3-28.7-64-64-64H64c-35.3 0-64 28.7-64 64v96c0 17.7 14.3 32 32 32H64v64c0 35.3 28.7 64 64 64H384c35.3 0 64-28.7 64-64V384zm-16-88a24 24 0 1 1 0 48 24 24 0 1 1 0-48z"/></svg>
|
||||
@@ -261,7 +260,7 @@
|
||||
<div class="qs-fee-header">
|
||||
<label class="qs-fee-label" for="oneTimeFee">Onboarding Fee</label>
|
||||
<label class="qs-toggle-row qs-fee-waive">
|
||||
<input type="checkbox" id="onboardingWaived" onchange="this.closest('.qs-fee-row').querySelector('#oneTimeFee').removeAttribute('data-manual'); update();">
|
||||
<input type="checkbox" id="onboardingWaived" onchange="onWaiveToggle();">
|
||||
<span class="qs-switch"></span>
|
||||
<span class="qs-toggle-label">Waive</span>
|
||||
</label>
|
||||
@@ -275,7 +274,11 @@
|
||||
</div>
|
||||
|
||||
<div class="sections-toolbar">
|
||||
<button class="btn-toggle-all" id="toggleAllBtn" onclick="toggleAllSections()"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle;margin-right:5px;"><polyline points="6 9 12 15 18 9"/><polyline points="6 15 12 9 18 15"/></svg>Collapse All</button>
|
||||
<button class="btn-toggle-all" id="toggleAllBtn" onclick="toggleAllSections()">
|
||||
<span class="toggle-all-collapse-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle;margin-right:5px;"><polyline points="6 9 12 15 18 9"/><polyline points="6 15 12 9 18 15"/></svg></span>
|
||||
<span class="toggle-all-expand-icon" style="display:none;"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle;margin-right:5px;"><polyline points="6 15 12 9 18 15"/><polyline points="6 9 12 15 18 9"/></svg></span>
|
||||
<span class="toggle-all-label">Collapse All</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ────────────────────────────────────────────────────────────
|
||||
@@ -341,10 +344,10 @@
|
||||
|
||||
<!-- What's Covered collapsible -->
|
||||
<div class="collapsible-header collapsible-header--mt16" onclick="toggleCollapsible('adminCovered')" tabindex="0" role="button" onkeydown="if(event.key==='Enter'||event.key===' '){toggleCollapsible('adminCovered');event.preventDefault();}">
|
||||
<span class="collapsible-toggle" id="adminCovered-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
|
||||
<span class="collapsible-toggle open" id="adminCovered-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
|
||||
<span class="collapsible-label">What's Covered by the Admin Fee</span>
|
||||
</div>
|
||||
<div class="collapsible-body" id="adminCovered">
|
||||
<div class="collapsible-body open" id="adminCovered">
|
||||
<div class="feature-card-grid">
|
||||
<div class="feature-card"><div class="feature-card-title"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512" width="16" height="16" fill="var(--accent)" style="margin-right:8px;flex-shrink:0;vertical-align:middle;"><path d="M96 0C43 0 0 43 0 96V416c0 53 43 96 96 96H344.2c-1.5-9.5-2.2-19.2-2.2-29.1V384H96c-17.7 0-32-14.3-32-32V96c0-17.7 14.3-32 32-32H416c17.7 0 32 14.3 32 32v84.2c19.4 6.7 37.3 17.3 52.9 30.6c5.3-2.7 11.3-4.8 17.1-4.8c23.7 0 42.9 19.2 42.9 42.9V272c16.8 10.4 32 23.4 44.8 38.8V176c0-53-43-96-96-96H416V96c0-53-43-96-96-96H96zM224 320a64 64 0 1 1 128 0 64 64 0 1 1 -128 0zm-32 64h192c17.7 0 32 14.3 32 32v32H160V416c0-17.7 14.3-32 32-32zM128 176c0-8.8 7.2-16 16-16h32c8.8 0 16 7.2 16 16v32c0 8.8-7.2 16-16 16H144c-8.8 0-16-7.2-16-16V176zm0 96c0-8.8 7.2-16 16-16h32c8.8 0 16 7.2 16 16v32c0 8.8-7.2 16-16 16H144c-8.8 0-16-7.2-16-16V272zm128-96c0-8.8 7.2-16 16-16h32c8.8 0 16 7.2 16 16v32c0 8.8-7.2 16-16 16H272c-8.8 0-16-7.2-16-16V176zm0 96c0-8.8 7.2-16 16-16h32c8.8 0 16 7.2 16 16v32c0 8.8-7.2 16-16 16H272c-8.8 0-16-7.2-16-16V272zm128-96c0-8.8 7.2-16 16-16h32c8.8 0 16 7.2 16 16v32c0 8.8-7.2 16-16 16H384c-8.8 0-16-7.2-16-16V176zM496 512a144 144 0 1 0 0-288 144 144 0 1 0 0 288zm0-96a48 48 0 1 1 0-96 48 48 0 1 1 0 96z"/></svg> Tenant & Identity Management</div><div class="feature-card-desc">Microsoft 365 / Entra ID tenant administration, user lifecycle, MFA enforcement, and conditional access policies.</div></div>
|
||||
<div class="feature-card"><div class="feature-card-title"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512" width="16" height="16" fill="var(--accent)" style="margin-right:8px;flex-shrink:0;vertical-align:middle;"><path d="M256 64H384v64H256V64zM240 0c-26.5 0-48 21.5-48 48v96c0 26.5 21.5 48 48 48h48v32H32c-17.7 0-32 14.3-32 32s14.3 32 32 32h176v32H160c-26.5 0-48 21.5-48 48v96c0 26.5 21.5 48 48 48h128c26.5 0 48-21.5 48-48V368c0-26.5-21.5-48-48-48H240V288H400v32H352c-26.5 0-48 21.5-48 48v96c0 26.5 21.5 48 48 48h128c26.5 0 48-21.5 48-48V368c0-26.5-21.5-48-48-48H432V288H608c17.7 0 32-14.3 32-32s-14.3-32-32-32H352V192h48c26.5 0 48-21.5 48-48V48c0-26.5-21.5-48-48-48H240zM192 400H288v64H192V400zm256 0H544v64H448V400z"/></svg> Network & Infrastructure Oversight</div><div class="feature-card-desc">Firewall configuration reviews, DNS management, VLAN segmentation oversight, and network performance monitoring.</div></div>
|
||||
@@ -380,9 +383,12 @@
|
||||
<div class="section-title">User Package</div>
|
||||
<div class="section-subtitle">Per-user monthly services — identity, email, security & helpdesk</div>
|
||||
<span class="section-badge">Per User / Month</span>
|
||||
<div class="sec-collapsed-counter">
|
||||
<button class="sec-count-btn" onclick="stepCount('userCount',-1,event)">−</button>
|
||||
<button class="sec-count-btn" onclick="stepCount('userCount',1,event)">+</button>
|
||||
<div class="sec-collapsed-counter" onclick="event.stopPropagation()">
|
||||
<div class="num-stepper">
|
||||
<button class="step-btn" onclick="stepInput('userCount',-1)">−</button>
|
||||
<input class="num-input" id="userCount" type="number" min="0" value="1" oninput="update()">
|
||||
<button class="step-btn" onclick="stepInput('userCount',1)">+</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span id="sec02-summary" class="sec-summary-badge"></span>
|
||||
@@ -414,14 +420,14 @@
|
||||
|
||||
<!-- What's Included collapsible -->
|
||||
<div class="collapsible-header" onclick="toggleCollapsible('userIncluded')" tabindex="0" role="button" onkeydown="if(event.key==='Enter'||event.key===' '){toggleCollapsible('userIncluded');event.preventDefault();}">
|
||||
<span class="collapsible-toggle" id="userIncluded-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
|
||||
<span class="collapsible-toggle open" id="userIncluded-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
|
||||
<span class="collapsible-label">What's Included in This Package</span>
|
||||
</div>
|
||||
<div class="collapsible-body" id="userIncluded">
|
||||
<div class="collapsible-body open" id="userIncluded">
|
||||
<ul class="feature-list">
|
||||
<li>Microsoft 365 Business Premium (M365 tier) — Word, Excel, PowerPoint, Teams, Exchange</li>
|
||||
<li>Entra ID & MFA — identity protection, conditional access, and SSO</li>
|
||||
<li>Microsoft Defender for Business — endpoint + email threat protection</li>
|
||||
<li class="m365-feature">Microsoft 365 Business Premium (M365 tier) — Word, Excel, PowerPoint, Teams, Exchange</li>
|
||||
<li class="m365-feature">Entra ID & MFA — identity protection, conditional access, and SSO</li>
|
||||
<li class="m365-feature">Microsoft Defender for Business — endpoint + email threat protection</li>
|
||||
<li>Helpdesk support (business hours) — tickets, remote sessions, escalations</li>
|
||||
<li>Onboarding & offboarding — provisioning, access revocation, equipment checklists</li>
|
||||
<li>Security awareness training (SAT) — phishing simulations & training modules</li>
|
||||
@@ -429,29 +435,19 @@
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="input-row">
|
||||
<div>
|
||||
<div class="input-label">Number of Users</div>
|
||||
</div>
|
||||
<div class="num-stepper">
|
||||
<button class="step-btn" onclick="stepInput('userCount',-1)">−</button>
|
||||
<input class="num-input" id="userCount" type="number" min="0" value="1" oninput="update()">
|
||||
<button class="step-btn" onclick="stepInput('userCount',1)">+</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Per-User Add-Ons collapsible -->
|
||||
<div class="collapsible-header collapsible-header--addon" onclick="toggleCollapsible('addonsA')" tabindex="0" role="button" onkeydown="if(event.key==='Enter'||event.key===' '){toggleCollapsible('addonsA');event.preventDefault();}">
|
||||
<span class="collapsible-toggle" id="addonsA-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
|
||||
<span class="collapsible-toggle open" id="addonsA-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
|
||||
<span class="collapsible-label">Per-User Add-Ons</span>
|
||||
<div id="addonsA-preview" class="addon-preview-wrap">
|
||||
<span class="addon-preview-pill">Extended Hours</span>
|
||||
<span class="addon-preview-pill">1Password</span>
|
||||
<span class="addon-preview-pill">INKY Pro</span>
|
||||
<span class="addon-preview-pill">Zero Trust</span>
|
||||
<div id="addonsA-preview" class="addon-preview-wrap" style="display:none">
|
||||
<span class="addon-preview-pill" data-addon="addExtHours">Extended Hours</span>
|
||||
<span class="addon-preview-pill" data-addon="addPWM">1Password</span>
|
||||
<span class="addon-preview-pill" data-addon="addINKY">INKY Pro</span>
|
||||
<span class="addon-preview-pill" data-addon="addZT">Zero Trust</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="collapsible-body" id="addonsA">
|
||||
<div class="collapsible-body open" id="addonsA">
|
||||
<div class="addon-grid">
|
||||
<label class="addon-row" id="row-ext" onclick="toggleAddon('addExtHours','row-ext');update()">
|
||||
<input type="checkbox" id="addExtHours">
|
||||
@@ -494,9 +490,12 @@
|
||||
<div class="section-title">Endpoint Package</div>
|
||||
<div class="section-subtitle">Per-device managed protection — workstations & laptops</div>
|
||||
<span class="section-badge">$35 / Endpoint / Month</span>
|
||||
<div class="sec-collapsed-counter">
|
||||
<button class="sec-count-btn" onclick="stepCount('endpointCount',-1,event)">−</button>
|
||||
<button class="sec-count-btn" onclick="stepCount('endpointCount',1,event)">+</button>
|
||||
<div class="sec-collapsed-counter" onclick="event.stopPropagation()">
|
||||
<div class="num-stepper">
|
||||
<button class="step-btn" onclick="stepInput('endpointCount',-1)">−</button>
|
||||
<input class="num-input" id="endpointCount" type="number" min="0" value="1" oninput="update()">
|
||||
<button class="step-btn" onclick="stepInput('endpointCount',1)">+</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span id="sec03-summary" class="sec-summary-badge"></span>
|
||||
@@ -507,10 +506,10 @@
|
||||
|
||||
<!-- What's Included collapsible -->
|
||||
<div class="collapsible-header" onclick="toggleCollapsible('endpointIncluded')" tabindex="0" role="button" onkeydown="if(event.key==='Enter'||event.key===' '){toggleCollapsible('endpointIncluded');event.preventDefault();}">
|
||||
<span class="collapsible-toggle" id="endpointIncluded-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
|
||||
<span class="collapsible-toggle open" id="endpointIncluded-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
|
||||
<span class="collapsible-label">What's Included in This Package</span>
|
||||
</div>
|
||||
<div class="collapsible-body" id="endpointIncluded">
|
||||
<div class="collapsible-body open" id="endpointIncluded">
|
||||
<ul class="feature-list">
|
||||
<li>RMM agent — remote monitoring, patching & automated remediation</li>
|
||||
<li>Huntress EDR — 24/7 SOC-backed threat hunting & incident response</li>
|
||||
@@ -521,28 +520,17 @@
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="input-row">
|
||||
<div>
|
||||
<div class="input-label">Number of Endpoints</div>
|
||||
<div class="input-sublabel">Workstations, laptops — per managed device</div>
|
||||
</div>
|
||||
<div class="num-stepper">
|
||||
<button class="step-btn" onclick="stepInput('endpointCount',-1)">−</button>
|
||||
<input class="num-input" id="endpointCount" type="number" min="0" value="1" oninput="update()">
|
||||
<button class="step-btn" onclick="stepInput('endpointCount',1)">+</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Per-Endpoint Add-Ons collapsible -->
|
||||
<div class="collapsible-header collapsible-header--addon" onclick="toggleCollapsible('addonsB')" tabindex="0" role="button" onkeydown="if(event.key==='Enter'||event.key===' '){toggleCollapsible('addonsB');event.preventDefault();}">
|
||||
<span class="collapsible-toggle" id="addonsB-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
|
||||
<span class="collapsible-toggle open" id="addonsB-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
|
||||
<span class="collapsible-label">Per-Endpoint Add-Ons</span>
|
||||
<div id="addonsB-preview" class="addon-preview-wrap">
|
||||
<span class="addon-preview-pill">Bare Metal Backup</span>
|
||||
<span class="addon-preview-pill">USB Blocking</span>
|
||||
<div id="addonsB-preview" class="addon-preview-wrap" style="display:none">
|
||||
<span class="addon-preview-pill" data-addon="addBMB">Bare Metal Backup</span>
|
||||
<span class="addon-preview-pill" data-addon="addUSB">USB Blocking</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="collapsible-body" id="addonsB">
|
||||
<div class="collapsible-body open" id="addonsB">
|
||||
<div class="addon-grid">
|
||||
<label class="addon-row" id="row-bmb" onclick="toggleAddon('addBMB','row-bmb');update()">
|
||||
<input type="checkbox" id="addBMB">
|
||||
@@ -576,9 +564,12 @@
|
||||
<div class="section-title">Server Management</div>
|
||||
<div class="section-subtitle">Dedicated management for physical & virtual servers</div>
|
||||
<span class="section-badge">$120 / Server / Month</span>
|
||||
<div class="sec-collapsed-counter">
|
||||
<button class="sec-count-btn" onclick="stepCount('serverCount',-1,event)">−</button>
|
||||
<button class="sec-count-btn" onclick="stepCount('serverCount',1,event)">+</button>
|
||||
<div class="sec-collapsed-counter" onclick="event.stopPropagation()">
|
||||
<div class="num-stepper">
|
||||
<button class="step-btn" onclick="stepInput('serverCount',-1)">−</button>
|
||||
<input class="num-input" id="serverCount" type="number" min="0" value="0" oninput="update()">
|
||||
<button class="step-btn" onclick="stepInput('serverCount',1)">+</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span id="sec04-summary" class="sec-summary-badge"></span>
|
||||
@@ -602,17 +593,6 @@
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="input-row">
|
||||
<div>
|
||||
<div class="input-label">Number of Servers</div>
|
||||
<div class="input-sublabel">Physical or virtual — each managed server</div>
|
||||
</div>
|
||||
<div class="num-stepper">
|
||||
<button class="step-btn" onclick="stepInput('serverCount',-1)">−</button>
|
||||
<input class="num-input" id="serverCount" type="number" min="0" value="0" oninput="update()">
|
||||
<button class="step-btn" onclick="stepInput('serverCount',1)">+</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -635,9 +615,12 @@
|
||||
<div class="section-title">Zero Trust Networking <span class="section-title-tag">HaaS</span></div>
|
||||
<div class="section-subtitle">Cytracom-powered ZT network access — seats & managed hardware as a service</div>
|
||||
<span class="section-badge">Per User + Per Device / Month</span>
|
||||
<div class="sec-collapsed-counter">
|
||||
<button class="sec-count-btn" onclick="stepCount('ztNetSeats',-1,event)">−</button>
|
||||
<button class="sec-count-btn" onclick="stepCount('ztNetSeats',1,event)">+</button>
|
||||
<div class="sec-collapsed-counter" onclick="event.stopPropagation()">
|
||||
<div class="num-stepper">
|
||||
<button class="step-btn" onclick="stepInput('ztNetSeats',-1)">−</button>
|
||||
<input class="num-input" id="ztNetSeats" type="number" min="0" value="0" oninput="update()">
|
||||
<button class="step-btn" onclick="stepInput('ztNetSeats',1)">+</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span id="sec05-summary" class="sec-summary-badge"></span>
|
||||
@@ -654,17 +637,6 @@
|
||||
Section V adds <em>ZT network infrastructure</em>: <strong>seats</strong> cover non-user devices (printers, IoT, cameras) that need network access control, and <strong>routers</strong> are the managed ZTNA gateway hardware delivered as a service. Both can be active together.</span>
|
||||
</div>
|
||||
|
||||
<div class="input-row">
|
||||
<div>
|
||||
<div class="input-label">5A — ZT User Seats</div>
|
||||
<div class="input-sublabel">Identity-aware access control per user · $25/seat/mo</div>
|
||||
</div>
|
||||
<div class="num-stepper">
|
||||
<button class="step-btn" onclick="stepInput('ztNetSeats',-1)">−</button>
|
||||
<input class="num-input" id="ztNetSeats" type="number" min="0" value="0" oninput="update()">
|
||||
<button class="step-btn" onclick="stepInput('ztNetSeats',1)">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-row">
|
||||
<div>
|
||||
<div class="input-label">5B — HaaS Devices</div>
|
||||
@@ -688,7 +660,7 @@
|
||||
.tier-seg.active set by activateTier() AND update()
|
||||
#voipSeats — seat count input
|
||||
#addVoipPhone — Desk Phone HaaS +$15/seat
|
||||
#addVoipFax — eFax line +$10 flat (not per seat)
|
||||
#addVoipFax — eFax line +$10/seat/mo
|
||||
#currentPhoneBill — optional savings comparator input
|
||||
#savingsComparator — green/amber result, rendered by updateSavings()
|
||||
voipTotal NOT counted in baseSubtotal (no effect on admin fee)
|
||||
@@ -701,6 +673,13 @@
|
||||
<div class="section-title">VoIP / Unified Communications <span class="section-title-tag">UCaaS</span></div>
|
||||
<div class="section-subtitle">United Cloud-powered business phone — seats, features & optional desk phones</div>
|
||||
<span class="section-badge">Per Seat / Month</span>
|
||||
<div class="sec-collapsed-counter" onclick="event.stopPropagation()">
|
||||
<div class="num-stepper">
|
||||
<button class="step-btn" onclick="stepInput('voipSeats',-1)">−</button>
|
||||
<input class="num-input" id="voipSeats" type="number" min="0" value="0" oninput="update()">
|
||||
<button class="step-btn" onclick="stepInput('voipSeats',1)">+</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span id="sec06-summary" class="sec-summary-badge"></span>
|
||||
<div class="sec-chevron"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></div>
|
||||
@@ -730,17 +709,6 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="input-row">
|
||||
<div>
|
||||
<div class="input-label">Number of Seats</div>
|
||||
<div class="input-sublabel">One seat per phone user</div>
|
||||
</div>
|
||||
<div class="num-stepper">
|
||||
<button class="step-btn" onclick="stepInput('voipSeats',-1)">−</button>
|
||||
<input class="num-input" id="voipSeats" type="number" min="0" value="0" oninput="update()">
|
||||
<button class="step-btn" onclick="stepInput('voipSeats',1)">+</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="addon-grid" style="margin-top:8px;">
|
||||
<label class="addon-row" id="row-vphone" onclick="toggleAddon('addVoipPhone','row-vphone');update()">
|
||||
@@ -783,6 +751,22 @@
|
||||
<div class="sidebar-title">SVS MSP — Live Quote</div>
|
||||
<div class="sidebar-client" id="clientNameDisplay">Client Name</div>
|
||||
</div>
|
||||
<!-- ── INSIGHT NUDGE BANNER ──────────────────────────────────────
|
||||
Sits flush under .sidebar-header, full width of .sidebar.
|
||||
Controlled entirely by renderNudge() via applyNudge("").
|
||||
Mobile duplicate: #nudgeBanner_m (synced via syncClass/syncEl).
|
||||
.hidden class toggled by renderNudge() when nudges=[]
|
||||
──────────────────────────────────────────────────────────────── -->
|
||||
<div id="nudgeBanner" class="nudge-banner amber hidden">
|
||||
<div class="nudge-header-row">
|
||||
<span class="nudge-banner-label" style="margin-bottom:0;"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512" width="12" height="14" fill="currentColor" style="margin-right:6px;vertical-align:middle;"><path d="M272 384c9.6-31.9 29.5-59.1 49.2-86.2l0 0c5.2-7.1 10.4-14.2 15.4-21.4c19.8-28.5 31.4-63 31.4-100.3C368 78.8 289.2 0 192 0S16 78.8 16 176c0 37.3 11.6 71.9 31.4 100.3c5 7.2 10.2 14.3 15.4 21.4l0 0c19.8 27.1 39.7 54.4 49.2 86.2H272zM192 512c44.2 0 80-35.8 80-80V416H112v16c0 44.2 35.8 80 80 80zM112 352H272c0 0 0 0 0 0H112c0 0 0 0 0 0z"/></svg> Insight <span id="nudgeCounter" class="nudge-counter"></span></span>
|
||||
<div class="nudge-nav-group">
|
||||
<button onclick="cycleNudge(-1)" class="nudge-nav-btn" title="Previous"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg></button>
|
||||
<button onclick="cycleNudge(1)" class="nudge-nav-btn" title="Next"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
<span id="nudgeText"></span>
|
||||
</div>
|
||||
<div class="sidebar-body">
|
||||
<!-- ── SIDEBAR SERVICE LINES ──────────────────────────────────
|
||||
Each .sidebar-line hidden by default.
|
||||
@@ -800,12 +784,12 @@
|
||||
<span><span class="lbl-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512" width="14" height="13" fill="currentColor" style="vertical-align:middle;"><path d="M144 0a80 80 0 1 1 0 160A80 80 0 1 1 144 0zM512 0a80 80 0 1 1 0 160A80 80 0 1 1 512 0zM0 298.7C0 239.8 47.8 192 106.7 192h42.7c15.9 0 31 3.5 44.6 9.7c-1.3 7.2-1.9 14.7-1.9 22.3c0 38.2 16.8 72.5 43.3 96c-.2 0-.4 0-.7 0H21.3C9.6 320 0 310.4 0 298.7zM405.3 320c-.2 0-.4 0-.7 0c26.6-23.5 43.3-57.8 43.3-96c0-7.6-.7-15-1.9-22.3c13.6-6.3 28.7-9.7 44.6-9.7h42.7C592.2 192 640 239.8 640 298.7c0 11.8-9.6 21.3-21.3 21.3H405.3zM224 224a96 96 0 1 1 192 0 96 96 0 1 1 -192 0zM128 485.3C128 411.7 187.7 352 261.3 352H378.7C452.3 352 512 411.7 512 485.3c0 14.7-11.9 26.7-26.7 26.7H154.7c-14.7 0-26.7-11.9-26.7-26.7z"/></svg></span> Users</span>
|
||||
<span class="val" id="sl-users-val">—</span>
|
||||
</div>
|
||||
<div class="sl-sub" style="display:none;" id="sl-users-sub"></div>
|
||||
<div class="sl-sub hidden" id="sl-users-sub"></div>
|
||||
<div class="sidebar-line hidden" id="sl-endpoints">
|
||||
<span><span class="lbl-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512" width="14" height="13" fill="currentColor" style="vertical-align:middle;"><path d="M64 0C28.7 0 0 28.7 0 64V352c0 35.3 28.7 64 64 64H240l-10.7 32H160c-17.7 0-32 14.3-32 32s14.3 32 32 32H416c17.7 0 32-14.3 32-32s-14.3-32-32-32H346.7L336 416H512c35.3 0 64-28.7 64-64V64c0-35.3-28.7-64-64-64H64zM512 64V352H64V64H512z"/></svg></span> Endpoints</span>
|
||||
<span class="val" id="sl-endpoints-val">—</span>
|
||||
</div>
|
||||
<div class="sl-sub" style="display:none;" id="sl-endpoints-sub"></div>
|
||||
<div class="sl-sub hidden" id="sl-endpoints-sub"></div>
|
||||
<div class="sidebar-line hidden" id="sl-servers">
|
||||
<span><span class="lbl-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="13" height="13" fill="currentColor" style="vertical-align:middle;"><path d="M64 32C28.7 32 0 60.7 0 96v64c0 35.3 28.7 64 64 64H448c35.3 0 64-28.7 64-64V96c0-35.3-28.7-64-64-64H64zm280 72a24 24 0 1 1 0 48 24 24 0 1 1 0-48zm48 24a24 24 0 1 1 48 0 24 24 0 1 1 -48 0zM64 288c-35.3 0-64 28.7-64 64v64c0 35.3 28.7 64 64 64H448c35.3 0 64-28.7 64-64V352c0-35.3-28.7-64-64-64H64zm280 72a24 24 0 1 1 0 48 24 24 0 1 1 0-48zm56 24a24 24 0 1 1 48 0 24 24 0 1 1 -48 0z"/></svg></span> Servers</span>
|
||||
<span class="val" id="sl-servers-val">—</span>
|
||||
@@ -864,7 +848,7 @@
|
||||
<span>Annual Projection</span>
|
||||
<span class="val" id="annualDisplay">$1,800</span>
|
||||
</div>
|
||||
<div class="sidebar-line" id="perUserRow" style="display:none;">
|
||||
<div class="sidebar-line hidden" id="perUserRow">
|
||||
<span>Avg. Cost Per User<br><small id="perUserBreakdown" class="per-user-cost-sub sidebar-note-mono hidden"></small></span>
|
||||
<span class="val" id="perUserDisplay">—</span>
|
||||
</div>
|
||||
@@ -899,25 +883,6 @@
|
||||
<div class="vs-footnote" id="vs-footnote"></div>
|
||||
</div>
|
||||
|
||||
<!-- ── INSIGHT NUDGE BANNER ────────────────────────────────────
|
||||
MUST remain inside .sidebar-body div.
|
||||
If this div is placed after the closing sidebar-body div
|
||||
it breaks on mobile (clipped outside container).
|
||||
Controlled entirely by renderNudge() via applyNudge("").
|
||||
Mobile duplicate: #nudgeBanner_m (synced via syncClass/syncEl).
|
||||
.hidden class toggled by renderNudge() when nudges=[]
|
||||
──────────────────────────────────────────────────────────────── -->
|
||||
<div id="nudgeBanner" class="nudge-banner amber hidden">
|
||||
<div class="nudge-header-row">
|
||||
<span class="nudge-banner-label" style="margin-bottom:0;"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512" width="12" height="14" fill="currentColor" style="margin-right:6px;vertical-align:middle;"><path d="M272 384c9.6-31.9 29.5-59.1 49.2-86.2l0 0c5.2-7.1 10.4-14.2 15.4-21.4c19.8-28.5 31.4-63 31.4-100.3C368 78.8 289.2 0 192 0S16 78.8 16 176c0 37.3 11.6 71.9 31.4 100.3c5 7.2 10.2 14.3 15.4 21.4l0 0c19.8 27.1 39.7 54.4 49.2 86.2H272zM192 512c44.2 0 80-35.8 80-80V416H112v16c0 44.2 35.8 80 80 80zM112 352H272c0 0 0 0 0 0H112c0 0 0 0 0 0z"/></svg> Insight <span id="nudgeCounter" class="nudge-counter"></span></span>
|
||||
<div class="nudge-nav-group">
|
||||
<button onclick="cycleNudge(-1)" class="nudge-nav-btn" title="Previous"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg></button>
|
||||
<button onclick="cycleNudge(1)" class="nudge-nav-btn" title="Next"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
<span id="nudgeText"></span>
|
||||
</div>
|
||||
|
||||
<!-- ── EXPORT BUTTONS ───────────────────────────────────────────
|
||||
Export A: window.print() — triggers browser print/save-as-PDF.
|
||||
Export B: exportQuoteJSON() — downloads .json + copies to clipboard.
|
||||
|
||||
@@ -40,13 +40,46 @@ let DISCOUNT_12MO = 0.03;
|
||||
let DISCOUNT_24MO = 0.05;
|
||||
let HST_RATE = 0.13; // Ontario HST 13%
|
||||
|
||||
// ── Nudge state — module-scoped (not window properties) ──────────
|
||||
let _nudges = [];
|
||||
let _nudgeIndex = 0;
|
||||
let _nudgeTimer;
|
||||
let _pricingFallbackShown = false;
|
||||
|
||||
function showPricingStatus(message) {
|
||||
const host = document.querySelector('.top-bar-right');
|
||||
if (!host) return;
|
||||
let el = document.getElementById('pricingStatus');
|
||||
if (!el) {
|
||||
el = document.createElement('div');
|
||||
el.id = 'pricingStatus';
|
||||
el.style.marginTop = '6px';
|
||||
el.style.fontSize = '11px';
|
||||
el.style.letterSpacing = '0.02em';
|
||||
el.style.color = 'var(--amber)';
|
||||
host.appendChild(el);
|
||||
}
|
||||
el.textContent = message;
|
||||
}
|
||||
|
||||
function reportPricingFallback(reason) {
|
||||
if (_pricingFallbackShown) return;
|
||||
_pricingFallbackShown = true;
|
||||
console.warn(`[SVS Quote] ${reason} Using built-in pricing defaults.`);
|
||||
showPricingStatus('Pricing CSV unavailable - using built-in defaults');
|
||||
}
|
||||
|
||||
// ── loadPricing() ────────────────────────────────────────────────
|
||||
// Fetches package-prices.csv and overrides the pricing variables above.
|
||||
// Silently falls back to built-in defaults if CSV is missing or malformed.
|
||||
// Falls back to built-in defaults with a visible warning if CSV fails.
|
||||
async function loadPricing() {
|
||||
let appliedKeys = 0;
|
||||
try {
|
||||
const res = await fetch('package-prices.csv');
|
||||
if (!res.ok) return;
|
||||
const res = await fetch('package-prices.csv', { cache: 'no-store' });
|
||||
if (!res.ok) {
|
||||
reportPricingFallback(`Could not load package-prices.csv (HTTP ${res.status}).`);
|
||||
return false;
|
||||
}
|
||||
const text = await res.text();
|
||||
const lines = text.split('\n').slice(1); // skip header row
|
||||
lines.forEach(line => {
|
||||
@@ -55,6 +88,7 @@ async function loadPricing() {
|
||||
const key = parts[1].trim();
|
||||
const val = parseFloat(parts[2].trim());
|
||||
if (isNaN(val)) return;
|
||||
let matched = true;
|
||||
switch (key) {
|
||||
case 'RATE_M365': RATE_M365 = val; break;
|
||||
case 'RATE_BYOL': RATE_BYOL = val; break;
|
||||
@@ -86,10 +120,18 @@ async function loadPricing() {
|
||||
case 'DISCOUNT_12MO': DISCOUNT_12MO = val; break;
|
||||
case 'DISCOUNT_24MO': DISCOUNT_24MO = val; break;
|
||||
case 'HST_RATE': HST_RATE = val; break;
|
||||
default: matched = false; break;
|
||||
}
|
||||
if (matched) appliedKeys++;
|
||||
});
|
||||
if (!appliedKeys) {
|
||||
reportPricingFallback('package-prices.csv loaded, but no recognized pricing keys were applied.');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
// CSV unavailable — built-in defaults remain active
|
||||
reportPricingFallback(`Could not load package-prices.csv (${e?.message || 'request failed'}).`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,7 +198,7 @@ function calcQuote() {
|
||||
const voipSeatRate = VOIP_RATES[voipTier] || VOIP_RATE_BASIC;
|
||||
const voipSeatsAmt = voipSeats * voipSeatRate;
|
||||
const voipPhoneAmt = addVoipPhone ? voipSeats * VOIP_PHONE_RATE : 0;
|
||||
const voipFaxAmt = addVoipFax ? VOIP_FAX_RATE : 0;
|
||||
const voipFaxAmt = addVoipFax ? voipSeats * VOIP_FAX_RATE : 0;
|
||||
const voipTotal = voipSeatsAmt + voipPhoneAmt + voipFaxAmt;
|
||||
|
||||
const MRR = userTotal + endpointTotal + adminFeeEffective + ztNetTotal + voipTotal;
|
||||
@@ -204,6 +246,7 @@ function calcQuote() {
|
||||
// always go through update() to keep _m panel in sync.
|
||||
function update() {
|
||||
const q = calcQuote();
|
||||
const m365BundleSavings = Math.max(0, RATE_M365 - RATE_BYOL);
|
||||
|
||||
// ── Onboarding fee: auto = 50% MRR unless manually set or waived ──
|
||||
// 12-month and 24-month contracts auto-waive the onboarding fee.
|
||||
@@ -272,11 +315,12 @@ function update() {
|
||||
const atFloor = baseSubtotal >= ADMIN_FEE_MINIMUM;
|
||||
getEl('floorBar').style.background = atFloor ? 'var(--green)' : 'var(--accent)';
|
||||
getEl('floorNote').textContent = atFloor
|
||||
? '✓ Minimum threshold reached — site fee at floor ($150/mo)'
|
||||
? `✓ Minimum threshold reached — site fee at floor (${fmt(ADMIN_FEE_FLOOR)}/mo)`
|
||||
: `$${Math.max(0, ADMIN_FEE_MINIMUM - baseSubtotal).toLocaleString()} more in services reduces admin fee further`;
|
||||
|
||||
getEl('fb-base').textContent = fmt(siteAdminBase);
|
||||
getEl('fb-zt-row').classList.toggle('hidden', !ztActive);
|
||||
getEl('fb-zt').textContent = '+' + fmt(ADMIN_FEE_ZT);
|
||||
getEl('fb-pwm-row').classList.toggle('hidden', !addPWM);
|
||||
getEl('fb-pwm').textContent = '+' + fmt(admin1PWM);
|
||||
if (adminWaived) {
|
||||
@@ -299,10 +343,11 @@ function update() {
|
||||
el.classList.toggle('hidden', !val);
|
||||
};
|
||||
show('sl-users', users > 0);
|
||||
getEl('sl-users-sub')?.classList.toggle('hidden', users === 0);
|
||||
if (users > 0) {
|
||||
getEl('sl-users-val').textContent = fmt(userTotal);
|
||||
const sub = getEl('sl-users-sub');
|
||||
sub.style.display = '';
|
||||
sub.classList.remove('hidden');
|
||||
const subParts = [`${users} × ${fmt(baseUserRate)}/user (${byol ? 'BYOL' : 'M365 Incl.'})`];
|
||||
if (addExtHours) subParts.push(`+ ${fmt(userExt)}/mo ext. hrs`);
|
||||
if (addPWM) subParts.push(`+ ${fmt(userPWM)}/mo 1Password`);
|
||||
@@ -311,13 +356,14 @@ function update() {
|
||||
sub.innerHTML = subParts.join('<br>');
|
||||
}
|
||||
show('sl-endpoints', endpoints > 0);
|
||||
getEl('sl-endpoints-sub')?.classList.toggle('hidden', endpoints === 0);
|
||||
if (endpoints > 0) {
|
||||
// endpointTotal includes serverBase — display endpoints-only so servers line doesn't double-count
|
||||
const epOnly = endpointTotal - serverBase;
|
||||
getEl('sl-endpoints-val').textContent = fmt(epOnly);
|
||||
const sub = getEl('sl-endpoints-sub');
|
||||
sub.style.display = '';
|
||||
const epParts = [`${endpoints} × $35/endpoint`];
|
||||
sub.classList.remove('hidden');
|
||||
const epParts = [`${endpoints} × ${fmt(RATE_ENDPOINT)}/endpoint`];
|
||||
if (addBMB) epParts.push(`+ ${fmt(endpointBMB)}/mo Bare Metal Backup`);
|
||||
if (addUSB) epParts.push(`+ ${fmt(endpointUSB)}/mo USB Blocking`);
|
||||
sub.innerHTML = epParts.join('<br>');
|
||||
@@ -342,7 +388,7 @@ function update() {
|
||||
// MRR + totals — show effective MRR (after term discount) as the headline number
|
||||
getEl('mrrDisplay').textContent = fmt(effectiveMrr);
|
||||
getEl('annualDisplay').textContent = fmt(effectiveAnnual);
|
||||
getEl('perUserRow').style.display = users > 0 ? '' : 'none';
|
||||
getEl('perUserRow').classList.toggle('hidden', users === 0);
|
||||
if (users > 0) getEl('perUserDisplay').textContent = fmt(effectiveMrr / users) + '/user';
|
||||
|
||||
// Discount row (only shown when a term discount is active)
|
||||
@@ -371,10 +417,6 @@ function update() {
|
||||
const totalEl = getEl('sl-hst-total-val');
|
||||
if (totalEl) totalEl.textContent = fmt(mrrWithHst) + '/mo';
|
||||
}
|
||||
// Sync mobile HST toggle state
|
||||
const hstToggleMobile = document.getElementById('hstToggle_m');
|
||||
if (hstToggleMobile) hstToggleMobile.checked = hstEnabled;
|
||||
|
||||
// Onboarding fee row — show fee, or "WAIVED" savings if waived
|
||||
const _waived = document.getElementById('onboardingWaived')?.checked || false;
|
||||
const _wouldBe = Math.round(q.MRR / 2);
|
||||
@@ -408,12 +450,13 @@ function update() {
|
||||
// Sidebar notes
|
||||
getEl('sideNote-m365').classList.toggle('hidden', byol);
|
||||
getEl('sideNote-byol').classList.toggle('hidden', !byol);
|
||||
if (!byol && users > 0) getEl('m365SaveAmt').textContent = fmt(users * 15);
|
||||
if (!byol && users > 0) getEl('m365SaveAmt').textContent = fmt(users * m365BundleSavings);
|
||||
|
||||
// BYOL callouts
|
||||
getEl('byolCalloutGreen').classList.toggle('hidden', byol);
|
||||
getEl('byolCalloutRed').classList.toggle('hidden', !byol);
|
||||
if (byol) getEl('byolRedSavings').textContent = fmt(users * 15);
|
||||
getEl('userIncluded').classList.toggle('byol-mode', byol);
|
||||
if (byol) getEl('byolRedSavings').textContent = fmt(users * m365BundleSavings);
|
||||
|
||||
// VoIP tier active state
|
||||
['basic','standard','premium'].forEach(t => {
|
||||
@@ -442,19 +485,19 @@ function update() {
|
||||
// Nudges — dynamic dollar values, context-sensitive conditions
|
||||
const nudges = [];
|
||||
if (!addZT && users > 0) nudges.push({
|
||||
text: `Zero Trust Deny By Default adds enterprise-grade access control — ${fmt(users * 55)}/mo for all ${users} user${users !== 1 ? 's' : ''}. Recommended for any client with remote staff or sensitive data.`,
|
||||
text: `Zero Trust Deny By Default adds enterprise-grade access control — ${fmt(users * ADDON_ZERO_TRUST_USER)}/mo for all ${users} user${users !== 1 ? 's' : ''}. Recommended for any client with remote staff or sensitive data.`,
|
||||
color: 'amber'
|
||||
});
|
||||
if (!addPWM && users > 0) nudges.push({
|
||||
text: `1Password Management — one vault for every credential, enforce strong passwords, revoke access instantly when staff leave. Only ${fmt(users * 9)}/mo for ${users} user${users !== 1 ? 's' : ''}.`,
|
||||
text: `1Password Management — one vault for every credential, enforce strong passwords, revoke access instantly when staff leave. Only ${fmt(users * ADDON_1PASSWORD)}/mo for ${users} user${users !== 1 ? 's' : ''}.`,
|
||||
color: 'green'
|
||||
});
|
||||
if (byol && users > 0) nudges.push({
|
||||
text: `BYOL selected — switching to M365 Included ($130/user) bundles the license and saves the client up to ${fmt(users * 15)}/mo vs retail Microsoft 365 pricing.`,
|
||||
text: `BYOL selected — switching to M365 Included (${fmt(RATE_M365)}/user) bundles the license and saves the client up to ${fmt(users * m365BundleSavings)}/mo vs retail Microsoft 365 pricing.`,
|
||||
color: 'green'
|
||||
});
|
||||
if (endpoints > 0 && !addBMB) nudges.push({
|
||||
text: `Bare Metal Backup protects against ransomware with image-level restore — ${fmt(endpoints * 25)}/mo covers all ${endpoints} endpoint${endpoints !== 1 ? 's' : ''}. Fast, bare-metal recovery if the worst happens.`,
|
||||
text: `Bare Metal Backup protects against ransomware with image-level restore — ${fmt(endpoints * ADDON_BARE_METAL_BACKUP)}/mo covers all ${endpoints} endpoint${endpoints !== 1 ? 's' : ''}. Fast, bare-metal recovery if the worst happens.`,
|
||||
color: 'amber'
|
||||
});
|
||||
if (voipSeats > 0 && voipTier === 'basic') nudges.push({
|
||||
@@ -469,28 +512,66 @@ function update() {
|
||||
text: `${endpoints} endpoints vs ${users} users — that's a high endpoint-to-user ratio. Consider whether unmanaged devices should be attached to user seats to ensure full coverage.`,
|
||||
color: 'amber'
|
||||
});
|
||||
window._nudges = nudges;
|
||||
if (window._nudgeIndex == null || window._nudgeIndex >= nudges.length) window._nudgeIndex = 0;
|
||||
_nudges = nudges;
|
||||
if (_nudgeIndex == null || _nudgeIndex >= nudges.length) _nudgeIndex = 0;
|
||||
|
||||
renderNudge();
|
||||
updateSavings(q);
|
||||
updateVsComparison(q);
|
||||
updateSectionSummaries(q);
|
||||
|
||||
// Highlight addon preview pills when their add-on is selected
|
||||
document.querySelectorAll('.addon-preview-pill[data-addon]').forEach(pill => {
|
||||
const cb = document.getElementById(pill.dataset.addon);
|
||||
pill.classList.toggle('active', cb?.checked || false);
|
||||
});
|
||||
|
||||
debouncedSave();
|
||||
}
|
||||
|
||||
// ── onWaiveToggle() ──────────────────────────────────────────────
|
||||
// Called from onchange on #onboardingWaived checkbox.
|
||||
// Clears the manual override flag on the fee input so auto-calc resumes,
|
||||
// then runs update(). Extracted from inline HTML attribute for clarity.
|
||||
function onWaiveToggle() {
|
||||
const feeInput = document.getElementById('oneTimeFee');
|
||||
if (feeInput) feeInput.removeAttribute('data-manual');
|
||||
update();
|
||||
}
|
||||
|
||||
// ── toggleSection(id) ────────────────────────────────────────────
|
||||
// Collapses/expands a numbered section card.
|
||||
// Adds/removes .sec-open on the section element.
|
||||
// .sec-open → chevron rotates 180deg (CSS), body shown (JS display).
|
||||
// Calls updateSectionSummaries() to show/hide summary badges.
|
||||
// Map: section ID → collapsible IDs that should auto-expand when section opens
|
||||
const _sectionCollapsibles = {
|
||||
'sec-01': ['adminCovered'],
|
||||
'sec-02': ['userIncluded', 'addonsA'],
|
||||
'sec-03': ['endpointIncluded', 'addonsB'],
|
||||
'sec-04': ['serverIncluded'],
|
||||
};
|
||||
|
||||
function toggleSection(id) {
|
||||
const section = document.getElementById(id);
|
||||
const body = document.getElementById(id + '-body');
|
||||
if (!section || !body) return;
|
||||
const isOpen = section.classList.toggle('sec-open');
|
||||
body.style.display = isOpen ? '' : 'none';
|
||||
updateSectionSummaries();
|
||||
// Auto-expand inner collapsibles when section opens
|
||||
if (isOpen && _sectionCollapsibles[id]) {
|
||||
_sectionCollapsibles[id].forEach(cid => {
|
||||
const cBody = document.getElementById(cid);
|
||||
const cIcon = document.getElementById(cid + '-icon');
|
||||
const cPreview = document.getElementById(cid + '-preview');
|
||||
if (cBody && !cBody.classList.contains('open')) {
|
||||
cBody.classList.add('open');
|
||||
if (cIcon) cIcon.classList.add('open');
|
||||
if (cPreview) cPreview.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
updateSectionSummaries(calcQuote());
|
||||
updateToggleAllBtn();
|
||||
}
|
||||
|
||||
@@ -505,17 +586,36 @@ function toggleAllSections() {
|
||||
const body = document.getElementById(id + '-body');
|
||||
if (!section || !body) return;
|
||||
if (anyOpen) { section.classList.remove('sec-open'); body.style.display = 'none'; }
|
||||
else { section.classList.add('sec-open'); body.style.display = ''; }
|
||||
else {
|
||||
section.classList.add('sec-open'); body.style.display = '';
|
||||
// Auto-expand inner collapsibles
|
||||
if (_sectionCollapsibles[id]) {
|
||||
_sectionCollapsibles[id].forEach(cid => {
|
||||
const cBody = document.getElementById(cid);
|
||||
const cIcon = document.getElementById(cid + '-icon');
|
||||
const cPreview = document.getElementById(cid + '-preview');
|
||||
if (cBody && !cBody.classList.contains('open')) {
|
||||
cBody.classList.add('open');
|
||||
if (cIcon) cIcon.classList.add('open');
|
||||
if (cPreview) cPreview.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
updateSectionSummaries();
|
||||
updateSectionSummaries(calcQuote());
|
||||
updateToggleAllBtn();
|
||||
}
|
||||
function updateToggleAllBtn() {
|
||||
const anyOpen = _allSecIds.some(id => document.getElementById(id)?.classList.contains('sec-open'));
|
||||
const btn = document.getElementById('toggleAllBtn');
|
||||
const collapseIcon = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle;margin-right:5px;"><polyline points="6 9 12 15 18 9"/><polyline points="6 15 12 9 18 15"/></svg>';
|
||||
const expandIcon = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle;margin-right:5px;"><polyline points="6 15 12 9 18 15"/><polyline points="6 9 12 15 18 9"/></svg>';
|
||||
if (btn) btn.innerHTML = anyOpen ? collapseIcon + 'Collapse All' : expandIcon + 'Expand All';
|
||||
if (!btn) return;
|
||||
const collapseSpan = btn.querySelector('.toggle-all-collapse-icon');
|
||||
const expandSpan = btn.querySelector('.toggle-all-expand-icon');
|
||||
const textSpan = btn.querySelector('.toggle-all-label');
|
||||
if (collapseSpan) collapseSpan.style.display = anyOpen ? '' : 'none';
|
||||
if (expandSpan) expandSpan.style.display = anyOpen ? 'none' : '';
|
||||
if (textSpan) textSpan.textContent = anyOpen ? 'Collapse All' : 'Expand All';
|
||||
}
|
||||
|
||||
// ── stepCount(inputId, delta, event) ─────────────────────────────
|
||||
@@ -537,7 +637,7 @@ function stepCount(inputId, delta, event) {
|
||||
// and text is non-empty; display:none otherwise.
|
||||
// Called by update() and toggleSection().
|
||||
function updateSectionSummaries(q) {
|
||||
q = q || calcQuote();
|
||||
if (!q) q = calcQuote(); // fallback only; always pass q explicitly from update()/toggleSection()
|
||||
const collapsed = id => !document.getElementById(id)?.classList.contains('sec-open');
|
||||
const setSummary = (id, text) => {
|
||||
const el = document.getElementById(id);
|
||||
@@ -626,9 +726,9 @@ function updateVsComparison(q) {
|
||||
const lbl = getEl(labelId);
|
||||
if (!row || !val || !lbl) return;
|
||||
val.textContent = fmtK(saving);
|
||||
row.className = row.className.replace(/\bvs-save-green\b|\bvs-save-amber\b/g, '').trim();
|
||||
val.className = val.className.replace(/\bvs-val-green\b|\bvs-val-amber\b/g, '').trim();
|
||||
lbl.className = lbl.className.replace(/\bvs-val-green\b|\bvs-val-amber\b/g, '').trim();
|
||||
row.classList.remove('vs-save-green', 'vs-save-amber');
|
||||
val.classList.remove('vs-val-green', 'vs-val-amber');
|
||||
lbl.classList.remove('vs-val-green', 'vs-val-amber');
|
||||
if (saving > 0) {
|
||||
row.classList.add('vs-save-green');
|
||||
val.classList.add('vs-val-green');
|
||||
@@ -647,18 +747,18 @@ function updateVsComparison(q) {
|
||||
const toolsLabel = toolsMonthly <= TOOL_COST_MIN
|
||||
? `min $${TOOL_COST_MIN}/mo`
|
||||
: `~$${toolsMonthly}/mo`;
|
||||
getEl('vs-footnote').textContent = `Based on ~$85K Ottawa IT salary (2024) + ${toolsLabel} tool licensing (M365, EDR, RMM, backup, SAT & more). No benefits, hiring, or turnover costs factored.`;
|
||||
getEl('vs-footnote').textContent = `Based on ~$${Math.round(IT_SALARY_1/1000)}K Ottawa IT salary + ${toolsLabel} tool licensing (M365, EDR, RMM, backup, SAT & more). No benefits, hiring, or turnover costs factored.`;
|
||||
}
|
||||
|
||||
// ── renderNudge() ─────────────────────────────────────────────────
|
||||
// Renders the active nudge insight in BOTH sidebar banners.
|
||||
// Reads window._nudges[] and window._nudgeIndex.
|
||||
// Reads module-scoped _nudges[] and _nudgeIndex.
|
||||
// applyNudge('') → desktop #nudgeBanner
|
||||
// applyNudge('_m') → mobile panel #nudgeBanner_m
|
||||
// Always call renderNudge() not applyNudge() directly.
|
||||
function renderNudge() {
|
||||
const nudges = window._nudges || [];
|
||||
const idx = window._nudgeIndex || 0;
|
||||
const nudges = _nudges;
|
||||
const idx = _nudgeIndex;
|
||||
|
||||
function applyNudge(suffix) {
|
||||
const s = suffix || '';
|
||||
@@ -684,9 +784,8 @@ function renderNudge() {
|
||||
// Manual nudge navigation. dir: +1 (next) or -1 (prev).
|
||||
// Does NOT reset the 30s auto-rotation timer (intentional).
|
||||
function cycleNudge(dir) {
|
||||
const nudges = window._nudges || [];
|
||||
if (!nudges.length) return;
|
||||
window._nudgeIndex = ((window._nudgeIndex || 0) + dir + nudges.length) % nudges.length;
|
||||
if (!_nudges.length) return;
|
||||
_nudgeIndex = (_nudgeIndex + dir + _nudges.length) % _nudges.length;
|
||||
renderNudge();
|
||||
}
|
||||
|
||||
@@ -696,11 +795,10 @@ function cycleNudge(dir) {
|
||||
// Called once on page load. Timer advances index directly
|
||||
// (does not call cycleNudge) so manual nav doesn't reset it.
|
||||
function startNudgeRotation() {
|
||||
if (window._nudgeTimer) clearInterval(window._nudgeTimer);
|
||||
window._nudgeTimer = setInterval(() => {
|
||||
const nudges = window._nudges || [];
|
||||
if (nudges.length > 1) {
|
||||
window._nudgeIndex = ((window._nudgeIndex || 0) + 1) % nudges.length;
|
||||
if (_nudgeTimer) clearInterval(_nudgeTimer);
|
||||
_nudgeTimer = setInterval(() => {
|
||||
if (_nudges.length > 1) {
|
||||
_nudgeIndex = (_nudgeIndex + 1) % _nudges.length;
|
||||
renderNudge();
|
||||
}
|
||||
}, 30000);
|
||||
@@ -728,10 +826,10 @@ function updateSavings(q) {
|
||||
const saving = bill - voipTotal;
|
||||
if (saving > 0) {
|
||||
comparator.textContent = `✓ Switching to SVS VoIP saves ~${fmt(saving)}/mo (${fmt(saving*12)}/yr) vs your current bill of ${fmt(bill)}/mo`;
|
||||
comparator.style.color = '';
|
||||
comparator.classList.remove('savings-amber');
|
||||
} else {
|
||||
comparator.textContent = `Your current bill (${fmt(bill)}/mo) is lower than this VoIP quote (${fmt(voipTotal)}/mo) — consider reviewing the tier or seat count.`;
|
||||
comparator.style.color = 'var(--amber)';
|
||||
comparator.classList.add('savings-amber');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -780,7 +878,7 @@ function saveState() {
|
||||
onboardingManual: document.getElementById('oneTimeFee')?.dataset.manual === '1',
|
||||
};
|
||||
localStorage.setItem(SAVE_KEY, JSON.stringify(state));
|
||||
} catch(e) {}
|
||||
} catch(e) { console.warn('saveState: failed to persist quote', e); }
|
||||
}
|
||||
let _saveTimer;
|
||||
function debouncedSave() {
|
||||
@@ -828,10 +926,10 @@ function restoreState() {
|
||||
if (feeEl) feeEl.dataset.manual = '1';
|
||||
}
|
||||
// Restore addon row selected states
|
||||
const rowMap = { addExtHours:'row-ext', addPWM:'row-pwm', addINKY:'row-inky', addZT:'row-zt', addBMB:'row-bmb', addUSB:'row-usb', addVoipPhone:'row-vphone', addVoipFax:'row-vfax' };
|
||||
['addExtHours','addPWM','addINKY','addZT','addBMB','addUSB','addVoipPhone','addVoipFax'].forEach(id => {
|
||||
const cb = document.getElementById(id);
|
||||
if (cb?.checked) {
|
||||
const rowMap = { addExtHours:'row-ext', addPWM:'row-pwm', addINKY:'row-inky', addZT:'row-zt', addBMB:'row-bmb', addUSB:'row-usb', addVoipPhone:'row-vphone', addVoipFax:'row-vfax' };
|
||||
const row = document.getElementById(rowMap[id]);
|
||||
if (row) row.classList.add('selected');
|
||||
}
|
||||
@@ -864,33 +962,44 @@ function printInvoice() {
|
||||
const row = (label, detail, amt, sub) => rows.push({label, detail, amt, sub: !!sub});
|
||||
|
||||
if (q.users > 0) {
|
||||
const pkg = q.byol ? 'BYOL — Bring Your Own License' : 'M365 Included (Identity, Email & Protection)';
|
||||
const pkg = q.byol ? 'BYOL — Bring Your Own License' : 'M365 Premium Included (Identity, Email & Protection)';
|
||||
row(`User Package — ${pkg}`, `${q.users} user${q.users!==1?'s':''} × ${fmt(q.baseUserRate)}/mo`, fmt(q.userBase));
|
||||
if (q.userExt > 0) row('↳ Extended Hours (+$25/user)', '', fmt(q.userExt), true);
|
||||
if (q.userPWM > 0) row('↳ 1Password Business (+$9/user)', '', fmt(q.userPWM), true);
|
||||
if (q.userINKY > 0) row('↳ Inky Email Security (+$5/user)', '', fmt(q.userINKY), true);
|
||||
if (q.userZT > 0) row('↳ Zero Trust User (+$55/user)', '', fmt(q.userZT), true);
|
||||
if (q.userExt > 0) row(`↳ Extended Hours (+${fmt(ADDON_EXT_HOURS)}/user)`, '', fmt(q.userExt), true);
|
||||
if (q.userPWM > 0) row(`↳ 1Password Business (+${fmt(ADDON_1PASSWORD)}/user)`, '', fmt(q.userPWM), true);
|
||||
if (q.userINKY > 0) row(`↳ Inky Email Security (+${fmt(ADDON_INKY)}/user)`, '', fmt(q.userINKY), true);
|
||||
if (q.userZT > 0) row(`↳ Zero Trust User (+${fmt(ADDON_ZERO_TRUST_USER)}/user)`, '', fmt(q.userZT), true);
|
||||
}
|
||||
if (q.endpoints > 0) {
|
||||
row('Endpoint Management', `${q.endpoints} endpoint${q.endpoints!==1?'s':''} × $35/mo`, fmt(q.endpointBase));
|
||||
if (q.endpointUSB > 0) row('↳ USB Blocking (+$4/endpoint)', '', fmt(q.endpointUSB), true);
|
||||
if (q.endpointBMB > 0) row('↳ Bare Metal Backup (+$25/endpoint)', '', fmt(q.endpointBMB), true);
|
||||
row('Endpoint Management', `${q.endpoints} endpoint${q.endpoints!==1?'s':''} × ${fmt(RATE_ENDPOINT)}/mo`, fmt(q.endpointBase));
|
||||
if (q.endpointUSB > 0) row(`↳ USB Blocking (+${fmt(ADDON_USB_BLOCKING)}/endpoint)`, '', fmt(q.endpointUSB), true);
|
||||
if (q.endpointBMB > 0) row(`↳ Bare Metal Backup (+${fmt(ADDON_BARE_METAL_BACKUP)}/endpoint)`, '', fmt(q.endpointBMB), true);
|
||||
}
|
||||
if (q.servers > 0) {
|
||||
row('Server Management', `${q.servers} server${q.servers!==1?'s':''} × $120/mo`, fmt(q.serverBase));
|
||||
row('Server Management', `${q.servers} server${q.servers!==1?'s':''} × ${fmt(RATE_SERVER)}/mo`, fmt(q.serverBase));
|
||||
}
|
||||
if (q.ztNetTotal > 0) {
|
||||
row('Zero Trust Networking — HaaS', '', fmt(q.ztNetTotal));
|
||||
if (q.ztNetSeats > 0) row(`↳ ZT Seats (${q.ztSeats} × $25/mo)`, '', fmt(q.ztNetSeats), true);
|
||||
if (q.ztNetRouters > 0) row(`↳ HaaS Routers (${q.ztRouters} × $100/mo)`, '', fmt(q.ztNetRouters), true);
|
||||
if (q.ztNetSeats > 0) row(`↳ ZT Seats (${q.ztSeats} × ${fmt(ZT_SEAT_RATE)}/mo)`, '', fmt(q.ztNetSeats), true);
|
||||
if (q.ztNetRouters > 0) row(`↳ HaaS Routers (${q.ztRouters} × ${fmt(ZT_ROUTER_RATE)}/mo)`, '', fmt(q.ztNetRouters), true);
|
||||
}
|
||||
if (q.voipTotal > 0) {
|
||||
const tier = {basic:'Basic',standard:'Standard',premium:'Premium'}[q.voipTier] || 'Basic';
|
||||
row(`VoIP / UCaaS — ${tier}`, `${q.voipSeats} seat${q.voipSeats!==1?'s':''} × $${q.voipSeatRate}/mo`, fmt(q.voipSeatsAmt));
|
||||
if (q.voipPhoneAmt > 0) row('↳ Desk Phone HaaS (+$15/seat)', '', fmt(q.voipPhoneAmt), true);
|
||||
if (q.voipFaxAmt > 0) row('↳ Virtual Fax (+$10/mo)', '', fmt(q.voipFaxAmt), true);
|
||||
if (q.voipPhoneAmt > 0) row(`↳ Desk Phone HaaS (+${fmt(VOIP_PHONE_RATE)}/seat)`, '', fmt(q.voipPhoneAmt), true);
|
||||
if (q.voipFaxAmt > 0) row(`↳ Virtual Fax (+${fmt(VOIP_FAX_RATE)}/mo)`, '', fmt(q.voipFaxAmt), true);
|
||||
}
|
||||
if (q.adminWaived) {
|
||||
row('Site Admin Fee', `Tenant, network, documentation & vendor management (waived; normally ${fmt(q.adminFeeNet)}/mo)`, fmt(0));
|
||||
} else {
|
||||
row('Site Admin Fee', 'Tenant, network, documentation & vendor management', fmt(q.adminFeeNet));
|
||||
}
|
||||
row(`↳ Base Site Admin`, '', fmt(q.siteAdminBase), true);
|
||||
if (q.ztActive) {
|
||||
row(`↳ Zero Trust Supplement`, '', fmt(ADMIN_FEE_ZT), true);
|
||||
}
|
||||
if (q.addPWM && q.admin1PWM > 0) {
|
||||
row(`↳ 1Password Management (${Math.round(ADMIN_1PWM_PCT * 100)}%)`, '', fmt(q.admin1PWM), true);
|
||||
}
|
||||
row('Site Admin Fee', 'Tenant, network, documentation & vendor management', fmt(q.adminFeeNet));
|
||||
|
||||
const itemsHTML = rows.map(r => `
|
||||
<tr${r.sub?' class="sub"':''}>
|
||||
@@ -899,6 +1008,29 @@ function printInvoice() {
|
||||
<td class="amt">${r.amt}/mo</td>
|
||||
</tr>`).join('');
|
||||
|
||||
// ── Build configuration summary ────────────────────────────────
|
||||
const features = [];
|
||||
const feat = (name, active, detail) => features.push({ name, active, detail: detail || '' });
|
||||
feat('Licensing Model', true, q.byol ? 'BYOL — Bring Your Own License' : 'M365 Premium Included');
|
||||
feat('Extended Help Desk Hours', q.addExtHours, q.addExtHours ? `+${fmt(ADDON_EXT_HOURS)}/user/mo` : '');
|
||||
feat('1Password Business', q.addPWM, q.addPWM ? `+${fmt(ADDON_1PASSWORD)}/user/mo` : '');
|
||||
feat('INKY Pro Email Security', q.addINKY, q.addINKY ? `+${fmt(ADDON_INKY)}/user/mo` : '');
|
||||
feat('Zero Trust User Access', q.addZT, q.addZT ? `+${fmt(ADDON_ZERO_TRUST_USER)}/user/mo` : '');
|
||||
feat('USB Device Blocking', q.addUSB, q.addUSB ? `+${fmt(ADDON_USB_BLOCKING)}/endpoint/mo` : '');
|
||||
feat('Bare Metal Backup', q.addBMB, q.addBMB ? `+${fmt(ADDON_BARE_METAL_BACKUP)}/endpoint/mo` : '');
|
||||
feat('Zero Trust Networking (HaaS)', q.ztNetTotal > 0, q.ztNetTotal > 0 ? `${q.ztSeats} seats, ${q.ztRouters} routers` : '');
|
||||
feat('VoIP / UCaaS', q.voipTotal > 0, q.voipTotal > 0 ? `${({basic:'Basic',standard:'Standard',premium:'Premium'})[q.voipTier]} — ${q.voipSeats} seats` : '');
|
||||
feat('Desk Phone HaaS', q.addVoipPhone, q.addVoipPhone ? `+${fmt(VOIP_PHONE_RATE)}/seat/mo` : '');
|
||||
feat('Virtual Fax', q.addVoipFax, q.addVoipFax ? `+${fmt(VOIP_FAX_RATE)}/mo` : '');
|
||||
|
||||
const configHTML = features.map(f => `
|
||||
<div class="cfg-item${f.active ? '' : ' cfg-inactive'}">
|
||||
<span class="cfg-icon">${f.active ? '✓' : '✕'}</span>
|
||||
<span class="cfg-name">${f.name}</span>
|
||||
${f.active && f.detail ? `<span class="cfg-detail">${f.detail}</span>` : ''}
|
||||
${!f.active ? '<span class="cfg-not-inc">Not Included</span>' : ''}
|
||||
</div>`).join('');
|
||||
|
||||
// ── Build totals ───────────────────────────────────────────────
|
||||
let totals = '';
|
||||
if (q.discountPct > 0) {
|
||||
@@ -906,8 +1038,12 @@ function printInvoice() {
|
||||
totals += `<tr class="t-muted"><td colspan="2">Term Discount (${Math.round(q.discountPct*100)}% off)</td><td>−${fmt(q.discountAmt)}/mo</td></tr>`;
|
||||
}
|
||||
totals += `<tr class="t-mrr"><td colspan="2">Monthly Recurring (MRR)</td><td>${fmt(q.effectiveMrr)}/mo</td></tr>`;
|
||||
if (q.users > 0) {
|
||||
totals += `<tr class="t-muted"><td colspan="2">Per-User Effective Cost</td><td>${fmt(q.effectiveMrr / q.users)}/user/mo</td></tr>`;
|
||||
totals += `<tr class="t-muted"><td colspan="2" style="padding-left:20px;font-size:11px">${fmt(q.perUserServices)} user services + ${fmt(q.perUserSiteOvhd)} site overhead</td><td></td></tr>`;
|
||||
}
|
||||
if (q.hstEnabled) {
|
||||
totals += `<tr class="t-muted"><td colspan="2">Ontario HST (13%)</td><td>+${fmt(q.hstAmt)}/mo</td></tr>`;
|
||||
totals += `<tr class="t-muted"><td colspan="2">Ontario HST (${Math.round(HST_RATE * 100)}%)</td><td>+${fmt(q.hstAmt)}/mo</td></tr>`;
|
||||
totals += `<tr class="t-total"><td colspan="2">Total Monthly</td><td>${fmt(q.mrrWithHst)}/mo</td></tr>`;
|
||||
}
|
||||
if (waived && waivedAmt > 0) {
|
||||
@@ -959,6 +1095,15 @@ body{font-family:'Poppins',Arial,sans-serif;font-size:13px;color:#1a1816;backgro
|
||||
.t-waived td:last-child{font-family:'DM Mono',monospace}
|
||||
.t-annual td{color:#6b6360;font-size:12px;border-bottom:none}
|
||||
.badge{display:inline-block;background:#e8f7ef;color:#217045;font-size:9px;font-family:'DM Mono',monospace;letter-spacing:.1em;padding:1px 6px;border-radius:3px;margin-left:6px;vertical-align:middle}
|
||||
/* ── Config summary ── */
|
||||
.cfg-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px 24px;margin-bottom:8px}
|
||||
.cfg-item{display:flex;align-items:baseline;gap:8px;font-size:11.5px;padding:4px 0}
|
||||
.cfg-icon{font-size:11px;font-weight:700;color:#217045;flex-shrink:0}
|
||||
.cfg-inactive .cfg-icon{color:#c0392b;font-size:10px;font-weight:400}
|
||||
.cfg-inactive .cfg-name{color:#999;text-decoration:line-through}
|
||||
.cfg-name{color:#1a1816}
|
||||
.cfg-not-inc{font-family:'DM Mono',monospace;font-size:9px;color:#c0392b;letter-spacing:.06em;margin-left:auto;text-transform:uppercase}
|
||||
.cfg-detail{font-family:'DM Mono',monospace;font-size:10px;color:#6b6360;margin-left:auto}
|
||||
/* ── Footer ── */
|
||||
.footer{margin-top:48px;padding-top:18px;border-top:1px solid #e8e4db;display:flex;justify-content:space-between;align-items:flex-end}
|
||||
.footer-left{font-family:'DM Mono',monospace;font-size:10px;color:#aaa;letter-spacing:.05em;line-height:1.9}
|
||||
@@ -982,13 +1127,16 @@ body{font-family:'Poppins',Arial,sans-serif;font-size:13px;color:#1a1816;backgro
|
||||
<div class="sec-lbl">Service Breakdown</div>
|
||||
<table class="items"><tbody>${itemsHTML}</tbody></table>
|
||||
|
||||
<div class="sec-lbl">Configuration Summary</div>
|
||||
<div class="cfg-grid">${configHTML}</div>
|
||||
|
||||
<div class="sec-lbl">Quote Summary</div>
|
||||
<table class="tots"><tbody>${totals}</tbody></table>
|
||||
|
||||
<div class="footer">
|
||||
<div>
|
||||
<div class="footer-left">SILICON VALLEY SERVICES · OTTAWA, ON</div>
|
||||
<div class="footer-note">This quote is valid for 30 days from the date above. All prices in CAD. HST at 13% applies on invoice unless already included. MRR is billed monthly in advance.</div>
|
||||
<div class="footer-note">This quote is valid for 30 days from the date above. All prices in CAD. HST at ${Math.round(HST_RATE * 100)}% applies on invoice unless already included. MRR is billed monthly in advance.</div>
|
||||
</div>
|
||||
<div style="font-family:'DM Mono',monospace;font-size:10px;color:#bbb;letter-spacing:.06em">${quoteDate}</div>
|
||||
</div>
|
||||
@@ -1149,8 +1297,10 @@ async function initQuote() {
|
||||
quoteRef = `SVS-${dateStr}-${String(Math.floor(Math.random()*9000)+1000)}`;
|
||||
localStorage.setItem('svs-msp-quote-ref', quoteRef);
|
||||
}
|
||||
document.getElementById('quoteRef').textContent = quoteRef;
|
||||
document.getElementById('headerDate').textContent = `${month} ${year}`;
|
||||
const quoteRefEl = document.getElementById('quoteRef');
|
||||
if (quoteRefEl) quoteRefEl.textContent = quoteRef;
|
||||
const headerDateEl = document.getElementById('headerDate');
|
||||
if (headerDateEl) headerDateEl.textContent = `${month} ${year}`;
|
||||
restoreState();
|
||||
update();
|
||||
}
|
||||
@@ -1158,6 +1308,23 @@ async function initQuote() {
|
||||
initTheme();
|
||||
initQuote();
|
||||
|
||||
// ── MOBILE SIDEBAR SYNC CONTRACT ──────────────────────────────────────
|
||||
// Every stateful sidebar element has a mirror ID with _m suffix.
|
||||
// The update() wrapper below syncs _m elements after each call to _origUpdate().
|
||||
//
|
||||
// WHEN ADDING A NEW SIDEBAR ELEMENT:
|
||||
// 1. Add the desktop element with its ID (e.g. #my-element)
|
||||
// 2. Add the mobile duplicate in #mobilePanelContent with ID #my-element_m
|
||||
// 3. Add the appropriate sync call in the update() wrapper:
|
||||
// syncEl(id) — copies innerHTML (text/HTML values)
|
||||
// syncClass(id) — copies className (.hidden toggling via classList)
|
||||
// syncStyle(id) — copies style.cssText (legacy inline display — avoid for new elements)
|
||||
// syncChecked(id) — copies .checked state (checkboxes)
|
||||
//
|
||||
// NEVER DOM-move the real .sidebar into the panel.
|
||||
// The duplicate HTML is intentional — moving breaks desktop layout on resize.
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
// ── MOBILE QUOTE PANEL IIFE ──────────────────────────────────────
|
||||
// Encapsulates all mobile panel logic to avoid polluting global scope.
|
||||
// ARCHITECTURE:
|
||||
@@ -1212,6 +1379,11 @@ initQuote();
|
||||
var dst = document.getElementById(id + '_m');
|
||||
if (src && dst) dst.style.cssText = src.style.cssText;
|
||||
}
|
||||
function syncChecked(id) {
|
||||
var src = document.getElementById(id);
|
||||
var dst = document.getElementById(id + '_m');
|
||||
if (src && dst) dst.checked = src.checked;
|
||||
}
|
||||
|
||||
// ── UPDATE WRAPPER ─────────────────────────────────────────────
|
||||
// Wraps the global update() to also sync the mobile panel.
|
||||
@@ -1276,9 +1448,12 @@ initQuote();
|
||||
syncClass('vs-5man-save');
|
||||
syncClass('vs-5man-save-lbl');
|
||||
syncClass('nudgeBanner');
|
||||
syncClass('adminWaivedSavings');
|
||||
syncEl('adminWaivedAmt');
|
||||
syncStyle('sl-users-sub');
|
||||
syncStyle('sl-endpoints-sub');
|
||||
syncStyle('perUserRow');
|
||||
syncChecked('hstToggle');
|
||||
// Pill MRR — show effective MRR with label
|
||||
var mrr = document.getElementById('mrrDisplay');
|
||||
var pill = document.getElementById('mobilePillMrr');
|
||||
|
||||
Reference in New Issue
Block a user