Before Theme Change
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 & helpdesk</div>
|
<div class="section-subtitle">Per-user monthly services — identity, email, security & 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)">−</button>
|
<div class="num-stepper">
|
||||||
<button class="sec-count-btn" onclick="stepCount('userCount',1,event)">+</button>
|
<button class="step-btn" onclick="stepInput('userCount',-1)">−</button>
|
||||||
|
<input class="num-input" id="userCount" type="number" min="0" value="1" oninput="update()">
|
||||||
|
<button class="step-btn" onclick="stepInput('userCount',1)">+</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</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 & MFA — identity protection, conditional access, and SSO</li>
|
<li class="m365-feature">Entra ID & MFA — identity protection, conditional access, and SSO</li>
|
||||||
<li>Microsoft Defender for Business — endpoint + email threat protection</li>
|
<li class="m365-feature">Microsoft 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 & offboarding — provisioning, access revocation, equipment checklists</li>
|
<li>Onboarding & offboarding — provisioning, access revocation, equipment checklists</li>
|
||||||
<li>Security awareness training (SAT) — phishing simulations & training modules</li>
|
<li>Security awareness training (SAT) — phishing simulations & 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)">−</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 & laptops</div>
|
<div class="section-subtitle">Per-device managed protection — workstations & 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)">−</button>
|
<div class="num-stepper">
|
||||||
<button class="sec-count-btn" onclick="stepCount('endpointCount',1,event)">+</button>
|
<button class="step-btn" onclick="stepInput('endpointCount',-1)">−</button>
|
||||||
|
<input class="num-input" id="endpointCount" type="number" min="0" value="1" oninput="update()">
|
||||||
|
<button class="step-btn" onclick="stepInput('endpointCount',1)">+</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</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 & automated remediation</li>
|
<li>RMM agent — remote monitoring, patching & automated remediation</li>
|
||||||
<li>Huntress EDR — 24/7 SOC-backed threat hunting & incident response</li>
|
<li>Huntress EDR — 24/7 SOC-backed threat hunting & 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)">−</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 & virtual servers</div>
|
<div class="section-subtitle">Dedicated management for physical & 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)">−</button>
|
<div class="num-stepper">
|
||||||
<button class="sec-count-btn" onclick="stepCount('serverCount',1,event)">+</button>
|
<button class="step-btn" onclick="stepInput('serverCount',-1)">−</button>
|
||||||
|
<input class="num-input" id="serverCount" type="number" min="0" value="0" oninput="update()">
|
||||||
|
<button class="step-btn" onclick="stepInput('serverCount',1)">+</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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)">−</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 & managed hardware as a service</div>
|
<div class="section-subtitle">Cytracom-powered ZT network access — seats & managed hardware as a service</div>
|
||||||
<span class="section-badge">Per User + Per Device / Month</span>
|
<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)">−</button>
|
<div class="num-stepper">
|
||||||
<button class="sec-count-btn" onclick="stepCount('ztNetSeats',1,event)">+</button>
|
<button class="step-btn" onclick="stepInput('ztNetSeats',-1)">−</button>
|
||||||
|
<input class="num-input" id="ztNetSeats" type="number" min="0" value="0" oninput="update()">
|
||||||
|
<button class="step-btn" onclick="stepInput('ztNetSeats',1)">+</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</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)">−</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 & optional desk phones</div>
|
<div class="section-subtitle">United Cloud-powered business phone — seats, features & 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)">−</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)">−</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.
|
||||||
|
|||||||
@@ -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(`↳ Base Site Admin`, '', fmt(q.siteAdminBase), true);
|
||||||
|
if (q.ztActive) {
|
||||||
|
row(`↳ Zero Trust Supplement`, '', fmt(ADMIN_FEE_ZT), true);
|
||||||
|
}
|
||||||
|
if (q.addPWM && q.admin1PWM > 0) {
|
||||||
|
row(`↳ 1Password Management (${Math.round(ADMIN_1PWM_PCT * 100)}%)`, '', fmt(q.admin1PWM), true);
|
||||||
}
|
}
|
||||||
row('Site Admin Fee', 'Tenant, network, documentation & vendor management', fmt(q.adminFeeNet));
|
|
||||||
|
|
||||||
const itemsHTML = rows.map(r => `
|
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 ? '✓' : '✕'}</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 · OTTAWA, ON</div>
|
<div class="footer-left">SILICON VALLEY SERVICES · OTTAWA, ON</div>
|
||||||
<div class="footer-note">This quote is valid for 30 days from the date above. All prices in CAD. HST at 13% applies on invoice unless already included. MRR is billed monthly in advance.</div>
|
<div class="footer-note">This quote is valid for 30 days from the date above. All prices in CAD. HST at ${Math.round(HST_RATE * 100)}% applies on invoice unless already included. MRR is billed monthly in advance.</div>
|
||||||
</div>
|
</div>
|
||||||
<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');
|
||||||
|
|||||||
Reference in New Issue
Block a user