Before Theme Change

This commit is contained in:
2026-03-13 10:24:58 -04:00
parent 1663c69c63
commit ac9420c812
4 changed files with 411 additions and 213 deletions

View File

@@ -69,6 +69,33 @@ body {
color: #7a1520 !important; 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 ──────────────────────────────────────────── */
.addon-row.selected { .addon-row.selected {
background: #dff0fb !important; background: #dff0fb !important;
@@ -132,6 +159,12 @@ body {
background: #eaf5ef !important; background: #eaf5ef !important;
border-color: #a8d5b8 !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 (green strip) ──────────────────────────────────── */
.pitch-footer { .pitch-footer {

View File

@@ -69,7 +69,7 @@
font-family: 'DM Mono', monospace; font-family: 'DM Mono', monospace;
font-size: 12px; font-size: 12px;
letter-spacing: 0.07em; letter-spacing: 0.07em;
color: #555; color: var(--muted);
text-align: right; text-align: right;
line-height: 1.6; line-height: 1.6;
margin-left: auto; margin-left: auto;
@@ -111,13 +111,13 @@
display: grid; display: grid;
grid-template-columns: 3fr 2fr; grid-template-columns: 3fr 2fr;
gap: 52px; gap: 52px;
padding: 52px clamp(20px,2vw,40px) 52px; padding: 50px clamp(20px,2vw,40px) 52px;
max-width: 1600px; max-width: 1600px;
margin: 0 auto; margin: 0 auto;
align-items: start; align-items: start;
} }
.main-col { display: flex; flex-direction: column; gap: 28px; } .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 ───────────────────────────────────────────────── /* ── CLIENT BAR ─────────────────────────────────────────────────
Lives inside .main-col, above section I. Lives inside .main-col, above section I.
@@ -166,7 +166,7 @@
margin-left: 96px; margin-left: 96px;
border-radius: 12px; border-radius: 12px;
border: 1px solid var(--border); 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; padding: 32px 36px 36px;
} }
.main-col > .section:first-of-type { margin-top: 24px; } .main-col > .section:first-of-type { margin-top: 24px; }
@@ -267,14 +267,11 @@
Buttons call stepCount() which stops propagation. Buttons call stepCount() which stops propagation.
─────────────────────────────────────────────────────────────── */ ─────────────────────────────────────────────────────────────── */
.sec-collapsed-counter { .sec-collapsed-counter {
display: none; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
margin-top: 12px; margin-top: 12px;
} }
.section:not(.sec-open) .sec-collapsed-counter {
display: flex;
}
.sec-count-btn { .sec-count-btn {
width: 44px; width: 44px;
height: 44px; height: 44px;
@@ -321,6 +318,10 @@
gap: 6px; gap: 6px;
} }
.pill-toggle label:last-child { border-right: none; } .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 { .pill-toggle input:checked + label {
background: var(--accent); background: var(--accent);
color: #fff; color: #fff;
@@ -350,6 +351,10 @@
margin-bottom: 20px; margin-bottom: 20px;
} }
.tier-seg-wrap input[type=radio] { display: none; } .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 { .tier-seg {
padding: 16px 10px; padding: 16px 10px;
cursor: pointer; cursor: pointer;
@@ -424,6 +429,12 @@
border-radius: 2px; border-radius: 2px;
padding: 3px 8px; padding: 3px 8px;
white-space: nowrap; 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 { .collapsible-body {
padding: 16px 0 20px 28px; padding: 16px 0 20px 28px;
@@ -459,6 +470,8 @@
color: var(--green); color: var(--green);
font-size: 11px; font-size: 11px;
} }
.byol-mode .m365-feature { text-decoration: line-through; opacity: 0.55; }
.byol-mode .m365-feature::before { color: var(--amber); }
/* ── NUMBER INPUTS ────────────────────────────────────────────── /* ── NUMBER INPUTS ──────────────────────────────────────────────
.input-row — flex row: label left, .num-input right .input-row — flex row: label left, .num-input right
@@ -660,7 +673,6 @@
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 12px; border-radius: 12px;
overflow: hidden; overflow: hidden;
margin-top: 100px;
} }
.sidebar-header { .sidebar-header {
padding: 20px 24px; padding: 20px 24px;
@@ -681,7 +693,7 @@
color: #fff; color: #fff;
line-height: 1.25; 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-body { padding: 24px; }
.sidebar-line { .sidebar-line {
display: flex; display: flex;
@@ -763,14 +775,13 @@
.nudge-nav-btn — SVG chevron pills ( ), hidden when only 1 nudge. .nudge-nav-btn — SVG chevron pills ( ), hidden when only 1 nudge.
BOTH #nudgeBanner and #nudgeBanner_m are updated by renderNudge() BOTH #nudgeBanner and #nudgeBanner_m are updated by renderNudge()
via applyNudge('') and applyNudge('_m'). via applyNudge('') and applyNudge('_m').
nudgeBanner MUST stay inside .sidebar-body div. nudgeBanner sits between .sidebar-header and .sidebar-body.
─────────────────────────────────────────────────────────────── */ ─────────────────────────────────────────────────────────────── */
.nudge-banner { .nudge-banner {
margin: 0; margin: 0 0 16px 0;
padding: 18px 24px; padding: 18px 24px;
font-size: 15px; font-size: 15px;
line-height: 1.7; line-height: 1.7;
border-top: 1px solid var(--border);
min-height: 130px; min-height: 130px;
box-sizing: border-box; box-sizing: border-box;
} }
@@ -945,6 +956,10 @@
user-select: none; user-select: none;
} }
.qs-toggle-row input[type=checkbox] { display: 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 { .qs-switch {
width: 34px; width: 34px;
height: 20px; height: 20px;
@@ -1032,7 +1047,7 @@
─────────────────────────────────────────────────────────────── */ ─────────────────────────────────────────────────────────────── */
/* Section I — admin fee display row */ /* 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-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-val { font-family: 'DM Mono', monospace; font-size: 22px; color: var(--accent); }
.admin-fee-sub { font-size: 12px; color: var(--muted); margin-bottom: 12px; } .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; } .sl-otf-waived .otf-waived-label { text-decoration: none; font-weight: 600; letter-spacing: 0.06em; }
/* ── ADMIN FEE WAIVED display */ /* ── 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-waive-toggle { margin-left: auto; }
.admin-fee-strike { text-decoration: line-through; color: var(--muted); text-decoration-color: var(--muted); } .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; } .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); color: var(--green);
line-height: 1.65; 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 ──────────────────────────────────────── /* ── BOTTOM PITCH BANNER ────────────────────────────────────────
4-column grid (2-col on tablet/mobile) outside the .outer grid. 4-column grid (2-col on tablet/mobile) outside the .outer grid.
@@ -1697,9 +1718,9 @@
bottom: 0; bottom: 0;
left: 0; left: 0;
right: 0; right: 0;
max-height: 92vh; max-height: 100vh;
background: var(--card); background: var(--card);
border-radius: 20px 20px 0 0; border-radius: 0;
overflow-y: auto; overflow-y: auto;
overscroll-behavior: contain; overscroll-behavior: contain;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
@@ -1764,12 +1785,16 @@
box-shadow: none !important; box-shadow: none !important;
overflow: visible !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 { .mobile-panel-sheet .sidebar-header {
display: none !important; display: block !important;
} }
.mobile-panel-sheet .sidebar-body { .mobile-panel-sheet .sidebar-body {
padding-top: 4px !important; padding-top: 0 !important;
}
.mobile-panel-sheet .nudge-banner {
margin-bottom: 35px;
} }
} }

View File

@@ -59,6 +59,17 @@
<div class="sidebar-title">SVS MSP — Live Quote</div> <div class="sidebar-title">SVS MSP — Live Quote</div>
<div class="sidebar-client" id="clientNameDisplay_m">Client Name</div> <div class="sidebar-client" id="clientNameDisplay_m">Client Name</div>
</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 class="sidebar-body">
<div id="sidebarLines_m"> <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> <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><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> <span class="val" id="sl-users-val_m"></span>
</div> </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"> <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><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> <span class="val" id="sl-endpoints-val_m"></span>
</div> </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"> <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><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> <span class="val" id="sl-servers-val_m"></span>
@@ -131,7 +142,7 @@
<span>Annual Projection</span> <span>Annual Projection</span>
<span class="val" id="annualDisplay_m">$1,800</span> <span class="val" id="annualDisplay_m">$1,800</span>
</div> </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>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> <span class="val" id="perUserDisplay_m"></span>
</div> </div>
@@ -160,18 +171,6 @@
</div> </div>
</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"> <div class="export-wrap">
<button class="btn-export" onclick="printInvoice()"> <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> <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"> <div class="qs-fee-header">
<label class="qs-fee-label" for="oneTimeFee">Onboarding Fee</label> <label class="qs-fee-label" for="oneTimeFee">Onboarding Fee</label>
<label class="qs-toggle-row qs-fee-waive"> <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-switch"></span>
<span class="qs-toggle-label">Waive</span> <span class="qs-toggle-label">Waive</span>
</label> </label>
@@ -275,7 +274,11 @@
</div> </div>
<div class="sections-toolbar"> <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> </div>
<!-- ──────────────────────────────────────────────────────────── <!-- ────────────────────────────────────────────────────────────
@@ -341,10 +344,10 @@
<!-- What's Covered collapsible --> <!-- 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();}"> <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> <span class="collapsible-label">What's Covered by the Admin Fee</span>
</div> </div>
<div class="collapsible-body" id="adminCovered"> <div class="collapsible-body open" id="adminCovered">
<div class="feature-card-grid"> <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="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> <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-title">User Package</div>
<div class="section-subtitle">Per-user monthly services — identity, email, security &amp; helpdesk</div> <div class="section-subtitle">Per-user monthly services — identity, email, security &amp; helpdesk</div>
<span class="section-badge">Per User / Month</span> <span class="section-badge">Per User / Month</span>
<div class="sec-collapsed-counter"> <div class="sec-collapsed-counter" onclick="event.stopPropagation()">
<button class="sec-count-btn" onclick="stepCount('userCount',-1,event)">&minus;</button> <div class="num-stepper">
<button class="sec-count-btn" onclick="stepCount('userCount',1,event)">+</button> <button class="step-btn" onclick="stepInput('userCount',-1)">&minus;</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>
</div> </div>
<span id="sec02-summary" class="sec-summary-badge"></span> <span id="sec02-summary" class="sec-summary-badge"></span>
@@ -414,14 +420,14 @@
<!-- What's Included collapsible --> <!-- 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();}"> <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> <span class="collapsible-label">What's Included in This Package</span>
</div> </div>
<div class="collapsible-body" id="userIncluded"> <div class="collapsible-body open" id="userIncluded">
<ul class="feature-list"> <ul class="feature-list">
<li>Microsoft 365 Business Premium (M365 tier) — Word, Excel, PowerPoint, Teams, Exchange</li> <li class="m365-feature">Microsoft 365 Business Premium (M365 tier) — Word, Excel, PowerPoint, Teams, Exchange</li>
<li>Entra ID &amp; MFA — identity protection, conditional access, and SSO</li> <li class="m365-feature">Entra ID &amp; MFA — identity protection, conditional access, and SSO</li>
<li>Microsoft Defender for Business — endpoint + email threat protection</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>Helpdesk support (business hours) — tickets, remote sessions, escalations</li>
<li>Onboarding &amp; offboarding — provisioning, access revocation, equipment checklists</li> <li>Onboarding &amp; offboarding — provisioning, access revocation, equipment checklists</li>
<li>Security awareness training (SAT) — phishing simulations &amp; training modules</li> <li>Security awareness training (SAT) — phishing simulations &amp; training modules</li>
@@ -429,29 +435,19 @@
</ul> </ul>
</div> </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)">&minus;</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 --> <!-- 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();}"> <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> <span class="collapsible-label">Per-User Add-Ons</span>
<div id="addonsA-preview" class="addon-preview-wrap"> <div id="addonsA-preview" class="addon-preview-wrap" style="display:none">
<span class="addon-preview-pill">Extended Hours</span> <span class="addon-preview-pill" data-addon="addExtHours">Extended Hours</span>
<span class="addon-preview-pill">1Password</span> <span class="addon-preview-pill" data-addon="addPWM">1Password</span>
<span class="addon-preview-pill">INKY Pro</span> <span class="addon-preview-pill" data-addon="addINKY">INKY Pro</span>
<span class="addon-preview-pill">Zero Trust</span> <span class="addon-preview-pill" data-addon="addZT">Zero Trust</span>
</div> </div>
</div> </div>
<div class="collapsible-body" id="addonsA"> <div class="collapsible-body open" id="addonsA">
<div class="addon-grid"> <div class="addon-grid">
<label class="addon-row" id="row-ext" onclick="toggleAddon('addExtHours','row-ext');update()"> <label class="addon-row" id="row-ext" onclick="toggleAddon('addExtHours','row-ext');update()">
<input type="checkbox" id="addExtHours"> <input type="checkbox" id="addExtHours">
@@ -494,9 +490,12 @@
<div class="section-title">Endpoint Package</div> <div class="section-title">Endpoint Package</div>
<div class="section-subtitle">Per-device managed protection — workstations &amp; laptops</div> <div class="section-subtitle">Per-device managed protection — workstations &amp; laptops</div>
<span class="section-badge">$35 / Endpoint / Month</span> <span class="section-badge">$35 / Endpoint / Month</span>
<div class="sec-collapsed-counter"> <div class="sec-collapsed-counter" onclick="event.stopPropagation()">
<button class="sec-count-btn" onclick="stepCount('endpointCount',-1,event)">&minus;</button> <div class="num-stepper">
<button class="sec-count-btn" onclick="stepCount('endpointCount',1,event)">+</button> <button class="step-btn" onclick="stepInput('endpointCount',-1)">&minus;</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>
</div> </div>
<span id="sec03-summary" class="sec-summary-badge"></span> <span id="sec03-summary" class="sec-summary-badge"></span>
@@ -507,10 +506,10 @@
<!-- What's Included collapsible --> <!-- 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();}"> <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> <span class="collapsible-label">What's Included in This Package</span>
</div> </div>
<div class="collapsible-body" id="endpointIncluded"> <div class="collapsible-body open" id="endpointIncluded">
<ul class="feature-list"> <ul class="feature-list">
<li>RMM agent — remote monitoring, patching &amp; automated remediation</li> <li>RMM agent — remote monitoring, patching &amp; automated remediation</li>
<li>Huntress EDR — 24/7 SOC-backed threat hunting &amp; incident response</li> <li>Huntress EDR — 24/7 SOC-backed threat hunting &amp; incident response</li>
@@ -521,28 +520,17 @@
</ul> </ul>
</div> </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)">&minus;</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 --> <!-- 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();}"> <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> <span class="collapsible-label">Per-Endpoint Add-Ons</span>
<div id="addonsB-preview" class="addon-preview-wrap"> <div id="addonsB-preview" class="addon-preview-wrap" style="display:none">
<span class="addon-preview-pill">Bare Metal Backup</span> <span class="addon-preview-pill" data-addon="addBMB">Bare Metal Backup</span>
<span class="addon-preview-pill">USB Blocking</span> <span class="addon-preview-pill" data-addon="addUSB">USB Blocking</span>
</div> </div>
</div> </div>
<div class="collapsible-body" id="addonsB"> <div class="collapsible-body open" id="addonsB">
<div class="addon-grid"> <div class="addon-grid">
<label class="addon-row" id="row-bmb" onclick="toggleAddon('addBMB','row-bmb');update()"> <label class="addon-row" id="row-bmb" onclick="toggleAddon('addBMB','row-bmb');update()">
<input type="checkbox" id="addBMB"> <input type="checkbox" id="addBMB">
@@ -576,9 +564,12 @@
<div class="section-title">Server Management</div> <div class="section-title">Server Management</div>
<div class="section-subtitle">Dedicated management for physical &amp; virtual servers</div> <div class="section-subtitle">Dedicated management for physical &amp; virtual servers</div>
<span class="section-badge">$120 / Server / Month</span> <span class="section-badge">$120 / Server / Month</span>
<div class="sec-collapsed-counter"> <div class="sec-collapsed-counter" onclick="event.stopPropagation()">
<button class="sec-count-btn" onclick="stepCount('serverCount',-1,event)">&minus;</button> <div class="num-stepper">
<button class="sec-count-btn" onclick="stepCount('serverCount',1,event)">+</button> <button class="step-btn" onclick="stepInput('serverCount',-1)">&minus;</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> </div>
<span id="sec04-summary" class="sec-summary-badge"></span> <span id="sec04-summary" class="sec-summary-badge"></span>
@@ -602,17 +593,6 @@
</ul> </ul>
</div> </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)">&minus;</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>
</div> </div>
@@ -635,9 +615,12 @@
<div class="section-title">Zero Trust Networking <span class="section-title-tag">HaaS</span></div> <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 &amp; managed hardware as a service</div> <div class="section-subtitle">Cytracom-powered ZT network access — seats &amp; managed hardware as a service</div>
<span class="section-badge">Per User + Per Device / Month</span> <span class="section-badge">Per User + Per Device / Month</span>
<div class="sec-collapsed-counter"> <div class="sec-collapsed-counter" onclick="event.stopPropagation()">
<button class="sec-count-btn" onclick="stepCount('ztNetSeats',-1,event)">&minus;</button> <div class="num-stepper">
<button class="sec-count-btn" onclick="stepCount('ztNetSeats',1,event)">+</button> <button class="step-btn" onclick="stepInput('ztNetSeats',-1)">&minus;</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>
</div> </div>
<span id="sec05-summary" class="sec-summary-badge"></span> <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> 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>
<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)">&minus;</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 class="input-row">
<div> <div>
<div class="input-label">5B — HaaS Devices</div> <div class="input-label">5B — HaaS Devices</div>
@@ -688,7 +660,7 @@
.tier-seg.active set by activateTier() AND update() .tier-seg.active set by activateTier() AND update()
#voipSeats — seat count input #voipSeats — seat count input
#addVoipPhone — Desk Phone HaaS +$15/seat #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 #currentPhoneBill — optional savings comparator input
#savingsComparator — green/amber result, rendered by updateSavings() #savingsComparator — green/amber result, rendered by updateSavings()
voipTotal NOT counted in baseSubtotal (no effect on admin fee) 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-title">VoIP / Unified Communications <span class="section-title-tag">UCaaS</span></div>
<div class="section-subtitle">United Cloud-powered business phone — seats, features &amp; optional desk phones</div> <div class="section-subtitle">United Cloud-powered business phone — seats, features &amp; optional desk phones</div>
<span class="section-badge">Per Seat / Month</span> <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)">&minus;</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> </div>
<span id="sec06-summary" class="sec-summary-badge"></span> <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> <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> </label>
</div> </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)">&minus;</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;"> <div class="addon-grid" style="margin-top:8px;">
<label class="addon-row" id="row-vphone" onclick="toggleAddon('addVoipPhone','row-vphone');update()"> <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-title">SVS MSP — Live Quote</div>
<div class="sidebar-client" id="clientNameDisplay">Client Name</div> <div class="sidebar-client" id="clientNameDisplay">Client Name</div>
</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"> <div class="sidebar-body">
<!-- ── SIDEBAR SERVICE LINES ────────────────────────────────── <!-- ── SIDEBAR SERVICE LINES ──────────────────────────────────
Each .sidebar-line hidden by default. 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><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> <span class="val" id="sl-users-val"></span>
</div> </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"> <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><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> <span class="val" id="sl-endpoints-val"></span>
</div> </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"> <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><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> <span class="val" id="sl-servers-val"></span>
@@ -864,7 +848,7 @@
<span>Annual Projection</span> <span>Annual Projection</span>
<span class="val" id="annualDisplay">$1,800</span> <span class="val" id="annualDisplay">$1,800</span>
</div> </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>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> <span class="val" id="perUserDisplay"></span>
</div> </div>
@@ -899,25 +883,6 @@
<div class="vs-footnote" id="vs-footnote"></div> <div class="vs-footnote" id="vs-footnote"></div>
</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 BUTTONS ───────────────────────────────────────────
Export A: window.print() — triggers browser print/save-as-PDF. Export A: window.print() — triggers browser print/save-as-PDF.
Export B: exportQuoteJSON() — downloads .json + copies to clipboard. Export B: exportQuoteJSON() — downloads .json + copies to clipboard.

View File

@@ -40,13 +40,46 @@ let DISCOUNT_12MO = 0.03;
let DISCOUNT_24MO = 0.05; let DISCOUNT_24MO = 0.05;
let HST_RATE = 0.13; // Ontario HST 13% 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() ──────────────────────────────────────────────── // ── loadPricing() ────────────────────────────────────────────────
// Fetches package-prices.csv and overrides the pricing variables above. // 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() { async function loadPricing() {
let appliedKeys = 0;
try { try {
const res = await fetch('package-prices.csv'); const res = await fetch('package-prices.csv', { cache: 'no-store' });
if (!res.ok) return; if (!res.ok) {
reportPricingFallback(`Could not load package-prices.csv (HTTP ${res.status}).`);
return false;
}
const text = await res.text(); const text = await res.text();
const lines = text.split('\n').slice(1); // skip header row const lines = text.split('\n').slice(1); // skip header row
lines.forEach(line => { lines.forEach(line => {
@@ -55,6 +88,7 @@ async function loadPricing() {
const key = parts[1].trim(); const key = parts[1].trim();
const val = parseFloat(parts[2].trim()); const val = parseFloat(parts[2].trim());
if (isNaN(val)) return; if (isNaN(val)) return;
let matched = true;
switch (key) { switch (key) {
case 'RATE_M365': RATE_M365 = val; break; case 'RATE_M365': RATE_M365 = val; break;
case 'RATE_BYOL': RATE_BYOL = 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_12MO': DISCOUNT_12MO = val; break;
case 'DISCOUNT_24MO': DISCOUNT_24MO = val; break; case 'DISCOUNT_24MO': DISCOUNT_24MO = val; break;
case 'HST_RATE': HST_RATE = 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) { } 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 voipSeatRate = VOIP_RATES[voipTier] || VOIP_RATE_BASIC;
const voipSeatsAmt = voipSeats * voipSeatRate; const voipSeatsAmt = voipSeats * voipSeatRate;
const voipPhoneAmt = addVoipPhone ? voipSeats * VOIP_PHONE_RATE : 0; 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 voipTotal = voipSeatsAmt + voipPhoneAmt + voipFaxAmt;
const MRR = userTotal + endpointTotal + adminFeeEffective + ztNetTotal + voipTotal; const MRR = userTotal + endpointTotal + adminFeeEffective + ztNetTotal + voipTotal;
@@ -204,6 +246,7 @@ function calcQuote() {
// always go through update() to keep _m panel in sync. // always go through update() to keep _m panel in sync.
function update() { function update() {
const q = calcQuote(); const q = calcQuote();
const m365BundleSavings = Math.max(0, RATE_M365 - RATE_BYOL);
// ── Onboarding fee: auto = 50% MRR unless manually set or waived ── // ── Onboarding fee: auto = 50% MRR unless manually set or waived ──
// 12-month and 24-month contracts auto-waive the onboarding fee. // 12-month and 24-month contracts auto-waive the onboarding fee.
@@ -272,11 +315,12 @@ function update() {
const atFloor = baseSubtotal >= ADMIN_FEE_MINIMUM; const atFloor = baseSubtotal >= ADMIN_FEE_MINIMUM;
getEl('floorBar').style.background = atFloor ? 'var(--green)' : 'var(--accent)'; getEl('floorBar').style.background = atFloor ? 'var(--green)' : 'var(--accent)';
getEl('floorNote').textContent = atFloor 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`; : `$${Math.max(0, ADMIN_FEE_MINIMUM - baseSubtotal).toLocaleString()} more in services reduces admin fee further`;
getEl('fb-base').textContent = fmt(siteAdminBase); getEl('fb-base').textContent = fmt(siteAdminBase);
getEl('fb-zt-row').classList.toggle('hidden', !ztActive); 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-row').classList.toggle('hidden', !addPWM);
getEl('fb-pwm').textContent = '+' + fmt(admin1PWM); getEl('fb-pwm').textContent = '+' + fmt(admin1PWM);
if (adminWaived) { if (adminWaived) {
@@ -299,10 +343,11 @@ function update() {
el.classList.toggle('hidden', !val); el.classList.toggle('hidden', !val);
}; };
show('sl-users', users > 0); show('sl-users', users > 0);
getEl('sl-users-sub')?.classList.toggle('hidden', users === 0);
if (users > 0) { if (users > 0) {
getEl('sl-users-val').textContent = fmt(userTotal); getEl('sl-users-val').textContent = fmt(userTotal);
const sub = getEl('sl-users-sub'); const sub = getEl('sl-users-sub');
sub.style.display = ''; sub.classList.remove('hidden');
const subParts = [`${users} × ${fmt(baseUserRate)}/user (${byol ? 'BYOL' : 'M365 Incl.'})`]; const subParts = [`${users} × ${fmt(baseUserRate)}/user (${byol ? 'BYOL' : 'M365 Incl.'})`];
if (addExtHours) subParts.push(`+ ${fmt(userExt)}/mo ext. hrs`); if (addExtHours) subParts.push(`+ ${fmt(userExt)}/mo ext. hrs`);
if (addPWM) subParts.push(`+ ${fmt(userPWM)}/mo 1Password`); if (addPWM) subParts.push(`+ ${fmt(userPWM)}/mo 1Password`);
@@ -311,13 +356,14 @@ function update() {
sub.innerHTML = subParts.join('<br>'); sub.innerHTML = subParts.join('<br>');
} }
show('sl-endpoints', endpoints > 0); show('sl-endpoints', endpoints > 0);
getEl('sl-endpoints-sub')?.classList.toggle('hidden', endpoints === 0);
if (endpoints > 0) { if (endpoints > 0) {
// endpointTotal includes serverBase — display endpoints-only so servers line doesn't double-count // endpointTotal includes serverBase — display endpoints-only so servers line doesn't double-count
const epOnly = endpointTotal - serverBase; const epOnly = endpointTotal - serverBase;
getEl('sl-endpoints-val').textContent = fmt(epOnly); getEl('sl-endpoints-val').textContent = fmt(epOnly);
const sub = getEl('sl-endpoints-sub'); const sub = getEl('sl-endpoints-sub');
sub.style.display = ''; sub.classList.remove('hidden');
const epParts = [`${endpoints} × $35/endpoint`]; const epParts = [`${endpoints} × ${fmt(RATE_ENDPOINT)}/endpoint`];
if (addBMB) epParts.push(`+ ${fmt(endpointBMB)}/mo Bare Metal Backup`); if (addBMB) epParts.push(`+ ${fmt(endpointBMB)}/mo Bare Metal Backup`);
if (addUSB) epParts.push(`+ ${fmt(endpointUSB)}/mo USB Blocking`); if (addUSB) epParts.push(`+ ${fmt(endpointUSB)}/mo USB Blocking`);
sub.innerHTML = epParts.join('<br>'); sub.innerHTML = epParts.join('<br>');
@@ -342,7 +388,7 @@ function update() {
// MRR + totals — show effective MRR (after term discount) as the headline number // MRR + totals — show effective MRR (after term discount) as the headline number
getEl('mrrDisplay').textContent = fmt(effectiveMrr); getEl('mrrDisplay').textContent = fmt(effectiveMrr);
getEl('annualDisplay').textContent = fmt(effectiveAnnual); 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'; if (users > 0) getEl('perUserDisplay').textContent = fmt(effectiveMrr / users) + '/user';
// Discount row (only shown when a term discount is active) // Discount row (only shown when a term discount is active)
@@ -371,10 +417,6 @@ function update() {
const totalEl = getEl('sl-hst-total-val'); const totalEl = getEl('sl-hst-total-val');
if (totalEl) totalEl.textContent = fmt(mrrWithHst) + '/mo'; 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 // Onboarding fee row — show fee, or "WAIVED" savings if waived
const _waived = document.getElementById('onboardingWaived')?.checked || false; const _waived = document.getElementById('onboardingWaived')?.checked || false;
const _wouldBe = Math.round(q.MRR / 2); const _wouldBe = Math.round(q.MRR / 2);
@@ -408,12 +450,13 @@ function update() {
// Sidebar notes // Sidebar notes
getEl('sideNote-m365').classList.toggle('hidden', byol); getEl('sideNote-m365').classList.toggle('hidden', byol);
getEl('sideNote-byol').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 // BYOL callouts
getEl('byolCalloutGreen').classList.toggle('hidden', byol); getEl('byolCalloutGreen').classList.toggle('hidden', byol);
getEl('byolCalloutRed').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 // VoIP tier active state
['basic','standard','premium'].forEach(t => { ['basic','standard','premium'].forEach(t => {
@@ -442,19 +485,19 @@ function update() {
// Nudges — dynamic dollar values, context-sensitive conditions // Nudges — dynamic dollar values, context-sensitive conditions
const nudges = []; const nudges = [];
if (!addZT && users > 0) nudges.push({ 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' color: 'amber'
}); });
if (!addPWM && users > 0) nudges.push({ 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' color: 'green'
}); });
if (byol && users > 0) nudges.push({ 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' color: 'green'
}); });
if (endpoints > 0 && !addBMB) nudges.push({ 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' color: 'amber'
}); });
if (voipSeats > 0 && voipTier === 'basic') nudges.push({ 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.`, 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' color: 'amber'
}); });
window._nudges = nudges; _nudges = nudges;
if (window._nudgeIndex == null || window._nudgeIndex >= nudges.length) window._nudgeIndex = 0; if (_nudgeIndex == null || _nudgeIndex >= nudges.length) _nudgeIndex = 0;
renderNudge(); renderNudge();
updateSavings(q); updateSavings(q);
updateVsComparison(q); updateVsComparison(q);
updateSectionSummaries(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(); 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) ──────────────────────────────────────────── // ── toggleSection(id) ────────────────────────────────────────────
// Collapses/expands a numbered section card. // Collapses/expands a numbered section card.
// Adds/removes .sec-open on the section element. // Adds/removes .sec-open on the section element.
// .sec-open → chevron rotates 180deg (CSS), body shown (JS display). // .sec-open → chevron rotates 180deg (CSS), body shown (JS display).
// Calls updateSectionSummaries() to show/hide summary badges. // 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) { function toggleSection(id) {
const section = document.getElementById(id); const section = document.getElementById(id);
const body = document.getElementById(id + '-body'); const body = document.getElementById(id + '-body');
if (!section || !body) return; if (!section || !body) return;
const isOpen = section.classList.toggle('sec-open'); const isOpen = section.classList.toggle('sec-open');
body.style.display = isOpen ? '' : 'none'; 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(); updateToggleAllBtn();
} }
@@ -505,17 +586,36 @@ function toggleAllSections() {
const body = document.getElementById(id + '-body'); const body = document.getElementById(id + '-body');
if (!section || !body) return; if (!section || !body) return;
if (anyOpen) { section.classList.remove('sec-open'); body.style.display = 'none'; } 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(); updateToggleAllBtn();
} }
function updateToggleAllBtn() { function updateToggleAllBtn() {
const anyOpen = _allSecIds.some(id => document.getElementById(id)?.classList.contains('sec-open')); const anyOpen = _allSecIds.some(id => document.getElementById(id)?.classList.contains('sec-open'));
const btn = document.getElementById('toggleAllBtn'); 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>'; if (!btn) return;
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>'; const collapseSpan = btn.querySelector('.toggle-all-collapse-icon');
if (btn) btn.innerHTML = anyOpen ? collapseIcon + 'Collapse All' : expandIcon + 'Expand All'; 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) ───────────────────────────── // ── stepCount(inputId, delta, event) ─────────────────────────────
@@ -537,7 +637,7 @@ function stepCount(inputId, delta, event) {
// and text is non-empty; display:none otherwise. // and text is non-empty; display:none otherwise.
// Called by update() and toggleSection(). // Called by update() and toggleSection().
function updateSectionSummaries(q) { 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 collapsed = id => !document.getElementById(id)?.classList.contains('sec-open');
const setSummary = (id, text) => { const setSummary = (id, text) => {
const el = document.getElementById(id); const el = document.getElementById(id);
@@ -626,9 +726,9 @@ function updateVsComparison(q) {
const lbl = getEl(labelId); const lbl = getEl(labelId);
if (!row || !val || !lbl) return; if (!row || !val || !lbl) return;
val.textContent = fmtK(saving); val.textContent = fmtK(saving);
row.className = row.className.replace(/\bvs-save-green\b|\bvs-save-amber\b/g, '').trim(); row.classList.remove('vs-save-green', 'vs-save-amber');
val.className = val.className.replace(/\bvs-val-green\b|\bvs-val-amber\b/g, '').trim(); val.classList.remove('vs-val-green', 'vs-val-amber');
lbl.className = lbl.className.replace(/\bvs-val-green\b|\bvs-val-amber\b/g, '').trim(); lbl.classList.remove('vs-val-green', 'vs-val-amber');
if (saving > 0) { if (saving > 0) {
row.classList.add('vs-save-green'); row.classList.add('vs-save-green');
val.classList.add('vs-val-green'); val.classList.add('vs-val-green');
@@ -647,18 +747,18 @@ function updateVsComparison(q) {
const toolsLabel = toolsMonthly <= TOOL_COST_MIN const toolsLabel = toolsMonthly <= TOOL_COST_MIN
? `min $${TOOL_COST_MIN}/mo` ? `min $${TOOL_COST_MIN}/mo`
: `~$${toolsMonthly}/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() ───────────────────────────────────────────────── // ── renderNudge() ─────────────────────────────────────────────────
// Renders the active nudge insight in BOTH sidebar banners. // 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('') → desktop #nudgeBanner
// applyNudge('_m') → mobile panel #nudgeBanner_m // applyNudge('_m') → mobile panel #nudgeBanner_m
// Always call renderNudge() not applyNudge() directly. // Always call renderNudge() not applyNudge() directly.
function renderNudge() { function renderNudge() {
const nudges = window._nudges || []; const nudges = _nudges;
const idx = window._nudgeIndex || 0; const idx = _nudgeIndex;
function applyNudge(suffix) { function applyNudge(suffix) {
const s = suffix || ''; const s = suffix || '';
@@ -684,9 +784,8 @@ function renderNudge() {
// Manual nudge navigation. dir: +1 (next) or -1 (prev). // Manual nudge navigation. dir: +1 (next) or -1 (prev).
// Does NOT reset the 30s auto-rotation timer (intentional). // Does NOT reset the 30s auto-rotation timer (intentional).
function cycleNudge(dir) { function cycleNudge(dir) {
const nudges = window._nudges || []; if (!_nudges.length) return;
if (!nudges.length) return; _nudgeIndex = (_nudgeIndex + dir + _nudges.length) % _nudges.length;
window._nudgeIndex = ((window._nudgeIndex || 0) + dir + nudges.length) % nudges.length;
renderNudge(); renderNudge();
} }
@@ -696,11 +795,10 @@ function cycleNudge(dir) {
// Called once on page load. Timer advances index directly // Called once on page load. Timer advances index directly
// (does not call cycleNudge) so manual nav doesn't reset it. // (does not call cycleNudge) so manual nav doesn't reset it.
function startNudgeRotation() { function startNudgeRotation() {
if (window._nudgeTimer) clearInterval(window._nudgeTimer); if (_nudgeTimer) clearInterval(_nudgeTimer);
window._nudgeTimer = setInterval(() => { _nudgeTimer = setInterval(() => {
const nudges = window._nudges || []; if (_nudges.length > 1) {
if (nudges.length > 1) { _nudgeIndex = (_nudgeIndex + 1) % _nudges.length;
window._nudgeIndex = ((window._nudgeIndex || 0) + 1) % nudges.length;
renderNudge(); renderNudge();
} }
}, 30000); }, 30000);
@@ -728,10 +826,10 @@ function updateSavings(q) {
const saving = bill - voipTotal; const saving = bill - voipTotal;
if (saving > 0) { 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.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 { } 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.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', onboardingManual: document.getElementById('oneTimeFee')?.dataset.manual === '1',
}; };
localStorage.setItem(SAVE_KEY, JSON.stringify(state)); localStorage.setItem(SAVE_KEY, JSON.stringify(state));
} catch(e) {} } catch(e) { console.warn('saveState: failed to persist quote', e); }
} }
let _saveTimer; let _saveTimer;
function debouncedSave() { function debouncedSave() {
@@ -828,10 +926,10 @@ function restoreState() {
if (feeEl) feeEl.dataset.manual = '1'; if (feeEl) feeEl.dataset.manual = '1';
} }
// Restore addon row selected states // 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 => { ['addExtHours','addPWM','addINKY','addZT','addBMB','addUSB','addVoipPhone','addVoipFax'].forEach(id => {
const cb = document.getElementById(id); const cb = document.getElementById(id);
if (cb?.checked) { 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]); const row = document.getElementById(rowMap[id]);
if (row) row.classList.add('selected'); 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}); const row = (label, detail, amt, sub) => rows.push({label, detail, amt, sub: !!sub});
if (q.users > 0) { 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)); 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.userExt > 0) row(`↳ Extended Hours (+${fmt(ADDON_EXT_HOURS)}/user)`, '', fmt(q.userExt), true);
if (q.userPWM > 0) row('↳ 1Password Business (+$9/user)', '', fmt(q.userPWM), true); if (q.userPWM > 0) row(`↳ 1Password Business (+${fmt(ADDON_1PASSWORD)}/user)`, '', fmt(q.userPWM), true);
if (q.userINKY > 0) row('↳ Inky Email Security (+$5/user)', '', fmt(q.userINKY), 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 (+$55/user)', '', fmt(q.userZT), true); if (q.userZT > 0) row(`↳ Zero Trust User (+${fmt(ADDON_ZERO_TRUST_USER)}/user)`, '', fmt(q.userZT), true);
} }
if (q.endpoints > 0) { if (q.endpoints > 0) {
row('Endpoint Management', `${q.endpoints} endpoint${q.endpoints!==1?'s':''} × $35/mo`, fmt(q.endpointBase)); row('Endpoint Management', `${q.endpoints} endpoint${q.endpoints!==1?'s':''} × ${fmt(RATE_ENDPOINT)}/mo`, fmt(q.endpointBase));
if (q.endpointUSB > 0) row('↳ USB Blocking (+$4/endpoint)', '', fmt(q.endpointUSB), true); if (q.endpointUSB > 0) row(`↳ USB Blocking (+${fmt(ADDON_USB_BLOCKING)}/endpoint)`, '', fmt(q.endpointUSB), true);
if (q.endpointBMB > 0) row('↳ Bare Metal Backup (+$25/endpoint)', '', fmt(q.endpointBMB), true); if (q.endpointBMB > 0) row(`↳ Bare Metal Backup (+${fmt(ADDON_BARE_METAL_BACKUP)}/endpoint)`, '', fmt(q.endpointBMB), true);
} }
if (q.servers > 0) { 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) { if (q.ztNetTotal > 0) {
row('Zero Trust Networking — HaaS', '', fmt(q.ztNetTotal)); 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.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} × $100/mo)`, '', fmt(q.ztNetRouters), true); if (q.ztNetRouters > 0) row(`↳ HaaS Routers (${q.ztRouters} × ${fmt(ZT_ROUTER_RATE)}/mo)`, '', fmt(q.ztNetRouters), true);
} }
if (q.voipTotal > 0) { if (q.voipTotal > 0) {
const tier = {basic:'Basic',standard:'Standard',premium:'Premium'}[q.voipTier] || 'Basic'; 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)); 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.voipPhoneAmt > 0) row(`↳ Desk Phone HaaS (+${fmt(VOIP_PHONE_RATE)}/seat)`, '', fmt(q.voipPhoneAmt), true);
if (q.voipFaxAmt > 0) row('↳ Virtual Fax (+$10/mo)', '', fmt(q.voipFaxAmt), 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('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);
}
const itemsHTML = rows.map(r => ` const itemsHTML = rows.map(r => `
<tr${r.sub?' class="sub"':''}> <tr${r.sub?' class="sub"':''}>
@@ -899,6 +1008,29 @@ function printInvoice() {
<td class="amt">${r.amt}/mo</td> <td class="amt">${r.amt}/mo</td>
</tr>`).join(''); </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 ? '&#10003;' : '&#10005;'}</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 ─────────────────────────────────────────────── // ── Build totals ───────────────────────────────────────────────
let totals = ''; let totals = '';
if (q.discountPct > 0) { 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-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>`; 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) { 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>`; totals += `<tr class="t-total"><td colspan="2">Total Monthly</td><td>${fmt(q.mrrWithHst)}/mo</td></tr>`;
} }
if (waived && waivedAmt > 0) { 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-waived td:last-child{font-family:'DM Mono',monospace}
.t-annual td{color:#6b6360;font-size:12px;border-bottom:none} .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} .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 ── */
.footer{margin-top:48px;padding-top:18px;border-top:1px solid #e8e4db;display:flex;justify-content:space-between;align-items:flex-end} .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} .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> <div class="sec-lbl">Service Breakdown</div>
<table class="items"><tbody>${itemsHTML}</tbody></table> <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> <div class="sec-lbl">Quote Summary</div>
<table class="tots"><tbody>${totals}</tbody></table> <table class="tots"><tbody>${totals}</tbody></table>
<div class="footer"> <div class="footer">
<div> <div>
<div class="footer-left">SILICON VALLEY SERVICES &nbsp;·&nbsp; OTTAWA, ON</div> <div class="footer-left">SILICON VALLEY SERVICES &nbsp;·&nbsp; 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>
<div style="font-family:'DM Mono',monospace;font-size:10px;color:#bbb;letter-spacing:.06em">${quoteDate}</div> <div style="font-family:'DM Mono',monospace;font-size:10px;color:#bbb;letter-spacing:.06em">${quoteDate}</div>
</div> </div>
@@ -1149,8 +1297,10 @@ async function initQuote() {
quoteRef = `SVS-${dateStr}-${String(Math.floor(Math.random()*9000)+1000)}`; quoteRef = `SVS-${dateStr}-${String(Math.floor(Math.random()*9000)+1000)}`;
localStorage.setItem('svs-msp-quote-ref', quoteRef); localStorage.setItem('svs-msp-quote-ref', quoteRef);
} }
document.getElementById('quoteRef').textContent = quoteRef; const quoteRefEl = document.getElementById('quoteRef');
document.getElementById('headerDate').textContent = `${month} ${year}`; if (quoteRefEl) quoteRefEl.textContent = quoteRef;
const headerDateEl = document.getElementById('headerDate');
if (headerDateEl) headerDateEl.textContent = `${month} ${year}`;
restoreState(); restoreState();
update(); update();
} }
@@ -1158,6 +1308,23 @@ async function initQuote() {
initTheme(); initTheme();
initQuote(); 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 ────────────────────────────────────── // ── MOBILE QUOTE PANEL IIFE ──────────────────────────────────────
// Encapsulates all mobile panel logic to avoid polluting global scope. // Encapsulates all mobile panel logic to avoid polluting global scope.
// ARCHITECTURE: // ARCHITECTURE:
@@ -1212,6 +1379,11 @@ initQuote();
var dst = document.getElementById(id + '_m'); var dst = document.getElementById(id + '_m');
if (src && dst) dst.style.cssText = src.style.cssText; 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 ───────────────────────────────────────────── // ── UPDATE WRAPPER ─────────────────────────────────────────────
// Wraps the global update() to also sync the mobile panel. // Wraps the global update() to also sync the mobile panel.
@@ -1276,9 +1448,12 @@ initQuote();
syncClass('vs-5man-save'); syncClass('vs-5man-save');
syncClass('vs-5man-save-lbl'); syncClass('vs-5man-save-lbl');
syncClass('nudgeBanner'); syncClass('nudgeBanner');
syncClass('adminWaivedSavings');
syncEl('adminWaivedAmt');
syncStyle('sl-users-sub'); syncStyle('sl-users-sub');
syncStyle('sl-endpoints-sub'); syncStyle('sl-endpoints-sub');
syncStyle('perUserRow'); syncStyle('perUserRow');
syncChecked('hstToggle');
// Pill MRR — show effective MRR with label // Pill MRR — show effective MRR with label
var mrr = document.getElementById('mrrDisplay'); var mrr = document.getElementById('mrrDisplay');
var pill = document.getElementById('mobilePillMrr'); var pill = document.getElementById('mobilePillMrr');