From 1232b1296158d3a757e67fdfdf623b949be75d32 Mon Sep 17 00:00:00 2001 From: John OReilly Date: Wed, 11 Mar 2026 15:26:39 -0400 Subject: [PATCH] Initial Commit --- SVS-MSP-Calculator-light.css | 134 +++ SVS-MSP-Calculator.css | 1695 ++++++++++++++++++++++++++++++++++ SVS-MSP-Calculator.html | 949 +++++++++++++++++++ SVS-MSP-Calculator.js | 1197 ++++++++++++++++++++++++ 4 files changed, 3975 insertions(+) create mode 100644 SVS-MSP-Calculator-light.css create mode 100644 SVS-MSP-Calculator.css create mode 100644 SVS-MSP-Calculator.html create mode 100644 SVS-MSP-Calculator.js diff --git a/SVS-MSP-Calculator-light.css b/SVS-MSP-Calculator-light.css new file mode 100644 index 0000000..ca56c87 --- /dev/null +++ b/SVS-MSP-Calculator-light.css @@ -0,0 +1,134 @@ +/* ══════════════════════════════════════════════════════════════ + SVS MSP Calculator — Light Theme + Imported dynamically by toggleTheme() when user switches to light mode. + Overrides :root design tokens and all hardcoded dark-mode colours + in SVS-MSP-Calculator.css. Never edit layout or structure here. + ══════════════════════════════════════════════════════════════ */ + +/* ── DESIGN TOKENS (light overrides) ───────────────────────────── */ +:root { + --ink: #1a1816; /* near-black for body text */ + --paper: #f4f2ed; /* warm off-white page background */ + --accent: #1a6a98; /* slightly darker blue for contrast */ + --accent2: #a03050; + --muted: #6b6360; /* mid-grey for secondary text */ + --border: #ccc9c3; /* light grey borders */ + --card: #ebe8e2; /* off-white card background */ + --green: #217045; /* darker green — accessible on white */ + --amber: #a05f00; /* darker amber — accessible on white */ +} + +/* ── BODY ────────────────────────────────────────────────────────── */ +body { + background: var(--paper); + color: var(--ink); +} + +/* ── TOP BAR ───────────────────────────────────────────────────── + Dark CSS sets background: var(--ink). In dark mode --ink is cream. + In light mode --ink becomes near-black, so we must pin it to cream + to keep the SVG logo (fill="#0c0c0c" text) legible. +─────────────────────────────────────────────────────────────────── */ +.top-bar { + background: #e8e4db !important; +} + +/* ── SECTION CARDS ───────────────────────────────────────────────── */ +.section { + background: #ffffff !important; +} + +/* ── CHEVRON PILL — swap white-alpha tint for dark-alpha ─────────── */ +.sec-chevron { + background: rgba(0, 0, 0, 0.04) !important; +} +.sec-open .sec-chevron, +.section-toggle:hover .sec-chevron { + background: rgba(0, 0, 0, 0.07) !important; +} + +/* ── SECTION HOVER / OPEN GLOW ───────────────────────────────────── */ +.section:hover { + border-color: rgba(26, 106, 152, 0.3) !important; + box-shadow: -3px 0 0 0 rgba(26, 106, 152, 0.35) !important; +} +.sec-open { + border-color: rgba(26, 106, 152, 0.45) !important; + box-shadow: -3px 0 0 0 rgba(26, 106, 152, 0.65) !important; +} + +/* ── CALLOUT BOXES ───────────────────────────────────────────────── */ +.callout-green { + background: #e8f7ef !important; + border-color: #3ab870 !important; + color: var(--green) !important; +} +.callout-red { + background: #fceef0 !important; + border-color: #c4526a !important; + color: #7a1520 !important; +} + +/* ── ADDON ROW SELECTED ──────────────────────────────────────────── */ +.addon-row.selected { + background: #dff0fb !important; + border-color: var(--accent) !important; + box-shadow: inset 3px 0 0 0 var(--accent) !important; +} +.addon-row.selected .addon-name { + color: var(--ink) !important; +} +.addon-row.selected .addon-price { + color: var(--accent) !important; +} + +/* ── NUDGE BANNER ────────────────────────────────────────────────── */ +.nudge-banner.amber { + background: #fff7e0 !important; + color: var(--amber) !important; +} +.nudge-banner.green { + background: #e8f7ef !important; + color: var(--green) !important; +} +.nudge-nav-btn { + background: rgba(0, 0, 0, 0.06) !important; +} +.nudge-nav-btn:hover { + background: rgba(0, 0, 0, 0.11) !important; +} + +/* ── VS COMPARISON ROWS ──────────────────────────────────────────── */ +.vs-save-green { background: #e8f5ee !important; } +.vs-save-amber { background: #fff8e1 !important; } + +/* ── SUMMARY BADGE ───────────────────────────────────────────────── */ +.sec-summary-badge { + background: rgba(26, 106, 152, 0.09) !important; + border-color: rgba(26, 106, 152, 0.28) !important; +} + +/* ── MOBILE PILL HOVER ───────────────────────────────────────────── */ +.mobile-quote-pill:hover { + background: #1a5f8a !important; +} + +/* ── THEME TOGGLE BUTTON (light-mode variant) ────────────────────── */ +.theme-toggle-btn { + background: rgba(0, 0, 0, 0.09) !important; + color: #2a2622 !important; +} +.theme-toggle-btn:hover { background: rgba(0, 0, 0, 0.15) !important; } +.theme-toggle-btn:active { background: rgba(0, 0, 0, 0.21) !important; } + +/* ── SAVINGS RESULT BOX ──────────────────────────────────────────── */ +.savings-result { + background: #eaf5ef !important; + border-color: #a8d5b8 !important; +} + +/* ── PITCH FOOTER (green strip) ──────────────────────────────────── */ +.pitch-footer { + background: #d4eddc !important; + border-top-color: #a8d5b8 !important; +} diff --git a/SVS-MSP-Calculator.css b/SVS-MSP-Calculator.css new file mode 100644 index 0000000..6d66a1a --- /dev/null +++ b/SVS-MSP-Calculator.css @@ -0,0 +1,1695 @@ + *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + + /* ── FOCUS VISIBLE ────────────────────────────────────────────── + Single rule covers all interactive elements — native inputs, + custom div toggles (section headers, collapsible headers), + addon rows, tier segments, and the theme toggle button. + Uses :focus-visible so mouse clicks don't show the ring. + ─────────────────────────────────────────────────────────────── */ + :focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } + /* Suppress the default outline on elements we've styled explicitly */ + .num-input:focus-visible, + .client-input:focus-visible, + .qs-fee-input:focus-visible, + .savings-input-row input:focus-visible { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px rgba(45,122,168,0.25); + } + /* ── DESIGN TOKENS ───────────────────────────────────────────── + Single source of truth for all colours. Edit here, not inline. + --accent2 (#c4526a) is defined but currently unused in UI. + ─────────────────────────────────────────────────────────────── */ + :root { + --ink: #ddd8d0; + --paper: #22201d; + --accent: #2d7aa8; + --accent2: #c4526a; + --muted: #a09890; + --border: #3a3630; + --card: #2a2722; + --green: #3ab870; + --amber: #e8920f; + } + body { + background: var(--paper); + color: var(--ink); + font-family: 'Lato', sans-serif; + font-size: 16px; + line-height: 1.75; + min-height: 100vh; + } + + /* ── TOP BAR ──────────────────────────────────────────────────── + Sticky header. z-index:100 sits below mobile panel (z:300) + and mobile pill (z:200). Background is --ink (cream) not --paper. + Contains: SVS logo SVG (inline) | quote ref + date (DM Mono). + ─────────────────────────────────────────────────────────────── */ + .top-bar { + position: sticky; + top: 0; + z-index: 100; + background: var(--ink); + border-bottom: 2px solid var(--accent); + padding: 14px 0; + display: flex; + justify-content: center; + } + .top-bar-inner { + width: 100%; + max-width: 1600px; + padding: 0 clamp(20px,2vw,40px); + display: flex; + align-items: center; + justify-content: space-between; + } + .top-bar-logo { margin-left: 70px; flex-shrink: 0; } + .top-bar-right { + font-family: 'DM Mono', monospace; + font-size: 12px; + letter-spacing: 0.07em; + color: #555; + text-align: right; + line-height: 1.6; + } + + /* ── THEME TOGGLE BUTTON ──────────────────────────────────────── + Positioned between logo and quote ref in .top-bar-inner. + Slightly darker chip vs the cream top-bar bg so it reads as + a distinct control, not noise. Works on both theme top-bars. + ─────────────────────────────────────────────────────────────── */ + .theme-toggle-btn { + background: rgba(0, 0, 0, 0.1); /* ~#c9c5bc chip on cream bar */ + border: none; + border-radius: 8px; + width: 36px; + height: 36px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: #3a3632; + transition: background 0.15s; + flex-shrink: 0; + margin-left: auto; + margin-right: 16px; + } + .theme-toggle-btn:hover { background: rgba(0, 0, 0, 0.17); } + .theme-toggle-btn:active { background: rgba(0, 0, 0, 0.23); } + .theme-toggle-btn svg { display: block; } + + /* ── PAGE LAYOUT ──────────────────────────────────────────────── + .outer — CSS grid, 3fr main / 2fr sidebar, max 1600px + .main-col — left: sections I–VI stacked vertically + .side-col — right: sticky sidebar (desktop only; hidden ≤1100px) + Roman numeral .section-num floats LEFT outside .section via + position:absolute + negative left offset (left: -96px). + This requires .section to have position:relative + margin-left:96px. + ─────────────────────────────────────────────────────────────── */ + .outer { + display: grid; + grid-template-columns: 3fr 2fr; + gap: 52px; + padding: 52px clamp(20px,2vw,40px) 52px; + max-width: 1600px; + margin: 0 auto; + align-items: start; + } + .main-col { display: flex; flex-direction: column; gap: 28px; } + .side-col { position: sticky; top: 72px; z-index: 10; align-self: start; } + + /* ── CLIENT BAR ───────────────────────────────────────────────── + Lives inside .main-col, above section I. + padding-left:96px aligns "PREPARED FOR" with section card edges + (matching the 96px margin-left on .section). + .client-input — contenteditable-style text input; oninput calls + update() which syncs clientNameDisplay in sidebar. + ─────────────────────────────────────────────────────────────── */ + .client-bar { + padding: 32px 0 32px 96px; + } + .client-label { + font-family: 'DM Mono', monospace; + font-size: 13px; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--muted); + margin-bottom: 10px; + } + .client-input { + background: transparent; + border: none; + border-bottom: 1px solid var(--border); + color: var(--accent); + font-family: 'Poppins', sans-serif; + font-weight: 600; + font-size: 30px; + width: 100%; + max-width: 480px; + outline: none; + padding: 2px 0; + } + .client-input::placeholder { color: var(--muted); opacity: 0.6; font-weight: 400; } + + /* ── SECTION CARDS (I–VI) ─────────────────────────────────────── + Each section = position:relative card with: + .section-num — absolute, floats left outside card (Cinzel) + .section-header — flex row: title-block | summary badge | chevron + .section-body — collapsible content (overflow:hidden, JS toggle) + JS toggleSection(id) adds/removes .sec-open class on .section. + .sec-open .sec-chevron rotates 180deg (down→up arrow). + .sec-summary-badge is shown/hidden by setSummary() in update(). + ─────────────────────────────────────────────────────────────── */ + .section { + position: relative; + margin-left: 96px; + border-radius: 12px; + border: 1px solid var(--border); + background: #1e1c18; + padding: 32px 36px 36px; + } + .main-col > .section:first-of-type { margin-top: 24px; } + .section-header { + display: flex; + align-items: flex-start; + gap: 12px; + margin-bottom: 32px; + } + .section-num { + font-family: 'Cinzel', serif; + font-weight: 700; + font-size: 62px; + line-height: 1; + color: var(--border); + flex-shrink: 0; + width: 80px; + user-select: none; + position: absolute; + left: -96px; + top: 32px; + text-align: right; + } + .section-title-block { flex: 1; min-width: 0; } + .section-title { + font-family: 'Poppins', sans-serif; + font-size: 24px; + font-weight: 600; + color: var(--ink); + line-height: 1.3; + word-break: break-word; + } + .section-title-tag { font-size: 15px; font-weight: 400; opacity: 0.6; } + .section-subtitle { + font-size: 14px; + color: var(--muted); + margin-top: 6px; + line-height: 1.55; + } + .section-badge { + font-family: 'DM Mono', monospace; + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.1em; + padding: 3px 8px; + border: 1px solid var(--border); + border-radius: 2px; + color: var(--muted); + display: inline-block; + margin-top: 8px; + } + .section-toggle { cursor: pointer; user-select: none; } + .sec-chevron { + display: flex; + align-items: center; + justify-content: center; + color: var(--muted); + transition: transform 0.25s ease, color 0.15s; + flex-shrink: 0; + transform: rotate(0deg); + width: 34px; + height: 34px; + background: rgba(255,255,255,0.05); + border-radius: 6px; + margin-top: 2px; + } + .sec-open .sec-chevron { transform: rotate(180deg); color: var(--ink); background: rgba(255,255,255,0.08); } + .section-toggle:hover .sec-chevron { color: var(--ink); background: rgba(255,255,255,0.08); } + .sec-chevron svg { display: block; } + .section-body { overflow: hidden; } + + /* ── SECTION SUMMARY BADGE ────────────────────────────────────── + Shown only when section is COLLAPSED (display:none by default). + JS: setSummary(id, text) sets textContent + display:inline-block + when collapsed(secId) && !!text. Hidden when section is open. + On mobile (≤600px) placed in grid col 2 row 1 (top-right of header). + ─────────────────────────────────────────────────────────────── */ + .sec-summary-badge { + display: none; + font-family: 'DM Mono', monospace; + font-size: 13px; + letter-spacing: 0.08em; + color: var(--accent); + background: rgba(45,122,168,0.12); + border: 1px solid rgba(45,122,168,0.3); + border-radius: 6px; + padding: 5px 11px; + white-space: nowrap; + flex-shrink: 0; + margin-top: 2px; + } + + /* ── PILL TOGGLE (Section II — M365 vs BYOL) ─────────────────── + CSS-only toggle using hidden radio inputs + adjacent label styling. + input:checked + label gets accent background. + JS reads: document.getElementById("rateBYOL").checked + On mobile (≤600px) stacks vertically (grid-template-columns:1fr). + ─────────────────────────────────────────────────────────────── */ + .pill-toggle { + display: grid; + grid-template-columns: 1fr 1fr; + border: 1px solid var(--border); + border-radius: 6px; + overflow: hidden; + margin-bottom: 20px; + } + .pill-toggle input[type=radio] { display: none; } + .pill-toggle label { + padding: 18px 20px; + cursor: pointer; + border-right: 1px solid var(--border); + transition: background 0.15s; + display: flex; + flex-direction: column; + gap: 6px; + } + .pill-toggle label:last-child { border-right: none; } + .pill-toggle input:checked + label { + background: var(--accent); + color: #fff; + } + .pill-toggle input:checked + label .pill-price { color: #fff; } + .pill-toggle input:checked + label .pill-desc { color: rgba(255,255,255,0.85); } + .pill-toggle label .pill-price { + font-family: 'DM Mono', monospace; + font-size: 20px; + font-weight: 500; + color: var(--ink); + } + .pill-toggle label .pill-price small { font-size: 14px; opacity: 0.6; } + .pill-toggle label .pill-desc { font-size: 14px; opacity: 0.7; } + + /* ── TIER SEGMENT (Section VI — VoIP Basic/Standard/Premium) ─── + 3-column radio toggle. JS activateTier(tier) adds .active class. + .active overrides text colours to white on accent background. + Rates: basic $28 | standard $35 | premium $45 /seat/mo. + ─────────────────────────────────────────────────────────────── */ + .tier-seg-wrap { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + border: 1px solid var(--border); + border-radius: 6px; + overflow: hidden; + margin-bottom: 20px; + } + .tier-seg-wrap input[type=radio] { display: none; } + .tier-seg { + padding: 16px 10px; + cursor: pointer; + border-right: 1px solid var(--border); + text-align: center; + transition: background 0.15s; + } + .tier-seg:last-of-type { border-right: none; } + .tier-seg.active { background: var(--accent); } + .tier-seg .tier-name { + font-family: 'DM Mono', monospace; + font-size: 14px; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--muted); + } + .tier-seg.active .tier-name { color: #fff; } + .tier-seg .tier-price { + font-family: 'DM Mono', monospace; + font-size: 22px; + color: var(--ink); + margin-top: 3px; + } + .tier-seg.active .tier-price { color: #fff; } + .tier-seg .tier-sub { font-size: 12px; color: var(--muted); margin-top: 2px; } + .tier-seg.active .tier-sub { color: rgba(255,255,255,0.7); } + + /* ── INNER COLLAPSIBLES (What's Included / Add-Ons) ──────────── + Separate from section-level collapse. JS toggleCollapsible(id) + toggles .open on .collapsible-body and swaps +/- on toggle icon. + .addon-preview-pill pills shown when collapsed (JS toggleCollapsible). + ─────────────────────────────────────────────────────────────── */ + .collapsible-header { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + padding: 14px 0; + border-top: 1px solid var(--border); + user-select: none; + } + .collapsible-header--mt16 { margin-top: 16px; } + .collapsible-header--addon { flex-wrap: wrap; gap: 4px; margin-top: 8px; } + .collapsible-toggle { + color: var(--accent); + width: 22px; + flex-shrink: 0; + display: flex; + align-items: center; + transition: transform 0.25s ease; + } + .collapsible-toggle.open { transform: rotate(180deg); } + .collapsible-toggle svg { display: block; } + .collapsible-label { + font-family: 'DM Mono', monospace; + font-size: 14px; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--muted); + } + .addon-preview-wrap { + display: flex; flex-wrap: wrap; gap: 5px; + width: 100%; padding-left: 28px; margin-top: 6px; + } + .addon-preview-pill { + font-family: 'DM Mono', monospace; + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--muted); + border: 1px solid var(--border); + border-radius: 2px; + padding: 3px 8px; + white-space: nowrap; + } + .collapsible-body { + padding: 16px 0 20px 28px; + overflow: hidden; + max-height: 0; + opacity: 0; + transition: max-height 0.3s ease, opacity 0.2s ease; + } + .collapsible-body.open { + max-height: 2000px; + opacity: 1; + } + + /* FEATURE LIST */ + .feature-list { + list-style: none; + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 8px; + } + .feature-list li { + font-size: 14px; + color: var(--muted); + line-height: 1.7; + padding-left: 18px; + position: relative; + } + .feature-list li::before { + content: '✓'; + position: absolute; + left: 0; + color: var(--green); + font-size: 11px; + } + + /* ── NUMBER INPUTS ────────────────────────────────────────────── + .input-row — flex row: label left, .num-input right + .num-input — DM Mono, text-align:center, oninput→update() + On mobile (≤600px) input-row stacks (flex-direction:column) + and num-input goes full width. + ─────────────────────────────────────────────────────────────── */ + .input-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 0; + border-top: 1px solid var(--border); + } + .input-label { font-family: 'Lato', sans-serif; font-weight: 700; font-size: 16px; } + .input-sublabel { font-size: 13px; color: var(--muted); margin-top: 5px; line-height: 1.55; } + .num-stepper { + display: flex; + align-items: stretch; + flex-shrink: 0; + } + .step-btn { + background: var(--card); + border: 1px solid var(--border); + color: var(--muted); + font-size: 20px; + font-weight: 400; + width: 36px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.12s, color 0.12s; + flex-shrink: 0; + user-select: none; + line-height: 1; + } + .step-btn:first-child { border-radius: 4px 0 0 4px; border-right: none; } + .step-btn:last-child { border-radius: 0 4px 4px 0; border-left: none; } + .step-btn:hover { background: var(--border); color: var(--ink); } + .step-btn:active { background: var(--accent); color: #fff; border-color: var(--accent); } + .num-input { + background: var(--card); + border: 1px solid var(--border); + border-radius: 0; + color: var(--ink); + font-family: 'DM Mono', monospace; + font-size: 22px; + width: 72px; + text-align: center; + padding: 8px; + outline: none; + } + .num-input:focus { border-color: var(--accent); } + .num-input::-webkit-inner-spin-button, + .num-input::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; } + .num-input[type=number] { -moz-appearance: textfield; } + + /* ── ADDON ROWS ───────────────────────────────────────────────── + .addon-row — clickable label wrapping a hidden checkbox. + JS toggleAddon(cbId, rowId) toggles .selected class on row. + .selected gets accent-tinted background + border. + Price is right-aligned via margin-left:auto on .addon-price. + ─────────────────────────────────────────────────────────────── */ + .addon-grid { display: flex; flex-direction: column; gap: 4px; } + .addon-row { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 13px 14px; + border-radius: 8px; + cursor: pointer; + border: 1px solid transparent; + transition: background 0.12s, border-color 0.12s; + } + .addon-row:hover { background: var(--card); } + .addon-row.selected { background: #1a2a38; border-color: #2a5070; } + .addon-row input[type=checkbox] { margin-top: 3px; accent-color: var(--accent); flex-shrink: 0; } + .addon-name { font-family: 'Lato', sans-serif; font-weight: 700; font-size: 15px; } + .addon-price { + font-family: 'DM Mono', monospace; + font-size: 14px; + color: var(--accent); + white-space: nowrap; + margin-left: auto; + flex-shrink: 0; + } + .addon-desc { font-size: 13px; color: var(--muted); margin-top: 5px; line-height: 1.6; } + + /* ── BYOL CALLOUTS (Section II) ──────────────────────────────── + Shown/hidden by JS based on BYOL toggle state. + #byolCalloutGreen — M365 savings message (shown when M365 selected) + #byolCalloutRed — missed savings warning (shown when BYOL selected) + display:flex + align-items:flex-start keeps icon pinned to + first line; text wrapped in so it never flows under icon. + ─────────────────────────────────────────────────────────────── */ + .callout-green { + background: #0d2016; + border: 1px solid #1e4a30; + border-radius: 6px; + padding: 14px 18px; + font-family: 'DM Mono', monospace; + font-size: 14px; + color: var(--green); + margin-bottom: 24px; + line-height: 1.65; + display: flex; + align-items: flex-start; + gap: 9px; + } + .callout-red { + background: #200d10; + border: 1px solid #4a1e24; + border-radius: 6px; + padding: 14px 18px; + font-family: 'DM Mono', monospace; + font-size: 14px; + color: #e06070; + margin-bottom: 24px; + line-height: 1.65; + display: flex; + align-items: flex-start; + gap: 9px; + } + .hidden { display: none !important; } + + /* ── ADMIN FEE PROGRESS BAR (Section I) ──────────────────────── + Shows progress toward $650 engagement threshold. + JS: progressEl width% = (baseSubtotal / ADMIN_FEE_MINIMUM) * 100 + baseSubtotal = users + endpoints + servers (NOT VoIP or ZT). + Turns green at 100% (threshold met), stays blue below. + ─────────────────────────────────────────────────────────────── */ + .progress-wrap { margin: 16px 0 10px; } + .progress-label { + font-family: 'DM Mono', monospace; + font-size: 12px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); + display: flex; + justify-content: space-between; + margin-bottom: 6px; + } + .progress-track { + height: 7px; + background: var(--border); + border-radius: 4px; + overflow: hidden; + } + .progress-fill { + height: 100%; + border-radius: 3px; + background: var(--accent); + transition: width 0.3s ease, background 0.3s; + } + + /* ── ADMIN FEE BREAKDOWN TABLE (Section I) ───────────────────── + Shows: Base Site Admin | ZT Supplement (if active) | Total. + .fee-total row has top border separator and bold text. + All values rendered by update() via getEl('sl-admin-val') etc. + ─────────────────────────────────────────────────────────────── */ + .fee-table { width: 100%; border-collapse: collapse; margin-top: 16px; font-size: 14px; } + .fee-table td { padding: 8px 0; color: var(--muted); } + .fee-table td:last-child { text-align: right; font-family: 'DM Mono', monospace; color: var(--ink); } + .fee-table tr.fee-total td { border-top: 1px solid var(--border); padding-top: 14px; color: var(--ink); font-weight: 600; } + + /* ── FEATURE CARDS (Section I — What's Covered) ──────────────── + Static content — 8 cards in single-column grid. + Each card has icon (inline SVG) + title (Poppins) + desc (Lato). + Not dynamically rendered; content is hard-coded in HTML. + ─────────────────────────────────────────────────────────────── */ + .feature-card-grid { + display: grid; + grid-template-columns: 1fr; + gap: 10px; + margin-top: 16px; + } + .feature-card { + background: var(--card); + border: 1px solid var(--border); + border-radius: 8px; + padding: 14px 16px; + } + .feature-card-title { font-family: 'Poppins', sans-serif; font-weight: 600; font-size: 14px; margin-bottom: 5px; display:flex; align-items:center; } + .feature-card-desc { font-size: 13px; color: var(--muted); line-height: 1.65; } + + /* ── SIDEBAR (Desktop only, ≤1100px hidden) ───────────────────── + .sidebar-header — accent blue, shows "SVS MSP — LIVE QUOTE" + client name + .sidebar-body — all pricing lines, MRR total, notes, VS comparison, + nudge banner, export button + .sidebar-line — each service line (icon + label + value) + .sidebar-mrr — large Poppins 48px MRR total + IMPORTANT: nudgeBanner MUST live inside .sidebar-body or it will + be clipped when sidebar collapses. Do not add before it. + On mobile: sidebar is hidden; a duplicate (_m IDs) lives inside + .mobile-panel-sheet and is synced by update() via syncEl/syncClass. + ─────────────────────────────────────────────────────────────── */ + .sidebar { + background: var(--card); + border: 1px solid var(--border); + border-radius: 12px; + overflow: hidden; + margin-top: 100px; + } + .sidebar-header { + padding: 20px 24px; + background: var(--accent); + } + .sidebar-title { + font-family: 'DM Mono', monospace; + font-size: 12px; + letter-spacing: 0.12em; + text-transform: uppercase; + color: rgba(255,255,255,0.75); + margin-bottom: 4px; + } + .sidebar-client { + font-family: 'Poppins', sans-serif; + font-weight: 600; + font-size: 22px; + color: #fff; + line-height: 1.25; + } + .sidebar-client.placeholder { color: rgba(255,255,255,0.35); font-weight: 400; font-style: italic; } + .sidebar-body { padding: 24px; } + .sidebar-line { + display: flex; + justify-content: space-between; + align-items: baseline; + font-size: 14px; + color: var(--muted); + padding: 8px 0; + border-bottom: 1px solid var(--border); + } + .sidebar-line .val { + font-family: 'DM Mono', monospace; + color: var(--ink); + font-size: 14px; + } + .sidebar-line .lbl-icon { margin-right: 5px; } + .sidebar-mrr-label { + font-family: 'DM Mono', monospace; + font-size: 13px; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--muted); + margin-top: 24px; + margin-bottom: 6px; + } + .sidebar-mrr { + font-family: 'Poppins', sans-serif; + font-weight: 700; + font-size: 48px; + color: var(--ink); + line-height: 1; + margin-bottom: 16px; + } + .sidebar-divider { border: none; border-top: 1px solid var(--border); margin: 20px 0; } + .sidebar-note { + font-size: 13px; + color: var(--muted); + line-height: 1.65; + padding: 6px 0; + } + .sidebar-note strong { color: var(--ink); } + + /* ── VS IN-HOUSE COMPARISON ───────────────────────────────────── + Shown only when users > 0 OR endpoints > 0 (JS toggles .hidden). + Compares SVS MRR (annualised) vs 1-person Ottawa IT hire ($85K+tools) + and 5-person team ($420K+tools). Savings rows turn amber if SVS + costs MORE than the comparison (rare at low seat counts). + updateVsComparison(q) renders this section in update(). + ─────────────────────────────────────────────────────────────── */ + .vs-table { width: 100%; border-collapse: collapse; font-size: 14px; margin-top: 8px; } + .vs-table td { padding: 7px 4px; } + .vs-table td:last-child { text-align: right; font-family: 'DM Mono', monospace; } + .vs-label { + font-family: 'DM Mono', monospace; + font-size: 12px; + letter-spacing: 0.09em; + text-transform: uppercase; + color: var(--muted); + margin-top: 18px; + margin-bottom: 6px; + } + .vs-save-row td { border-radius: 3px; padding: 5px 6px; font-size: 13px; } + + /* ── INSIGHT NUDGE BANNER ─────────────────────────────────────── + Contextual sales insight shown at bottom of sidebar. + Conditions (evaluated in update() → renderNudge()): + amber — ZT active + green — BYOL selected + users > 0 + green — PWM not selected + users > 0 + Auto-rotates every 30s via startNudgeRotation() (setInterval). + Manual nav via cycleNudge(dir) — does NOT reset the timer. + .nudge-nav-btn — SVG chevron pills (‹ ›), hidden when only 1 nudge. + BOTH #nudgeBanner and #nudgeBanner_m are updated by renderNudge() + via applyNudge('') and applyNudge('_m'). + nudgeBanner MUST stay inside .sidebar-body div. + ─────────────────────────────────────────────────────────────── */ + .nudge-banner { + margin: 0; + padding: 18px 24px; + font-size: 15px; + line-height: 1.7; + border-top: 1px solid var(--border); + } + .nudge-banner.amber { + background: #1f1500; + color: #d4901a; + border-left: 3px solid var(--amber); + } + .nudge-banner.green { + background: #1a2e20; + color: #3dc472; + border-left: 3px solid var(--green); + } + .nudge-banner-label { + font-family: 'DM Mono', monospace; + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.1em; + opacity: 1; + display: block; + margin-bottom: 8px; + } + #nudgeCounter { + font-size: 12px; + opacity: 0.55; + } + .nudge-nav-btn { + background: rgba(255,255,255,0.08); + border: none; + cursor: pointer; + padding: 0; + width: 28px; + height: 28px; + border-radius: 5px; + display: flex; + align-items: center; + justify-content: center; + color: inherit; + flex-shrink: 0; + transition: background 0.15s; + } + .nudge-nav-btn:hover { background: rgba(255,255,255,0.15); } + .nudge-nav-btn svg { display: block; } + + /* ── QUOTE SETTINGS BAR ───────────────────────────────────────── + Sits below the client bar, above Section I. + Left: contract term 3-way toggle. + Right: HST checkbox + one-time fee input. + padding-left:96px aligns with section card edges. + ─────────────────────────────────────────────────────────────── */ + .quote-settings-bar { + margin-left: 96px; + padding: 20px 28px 24px 28px; + display: flex; + align-items: flex-start; + gap: 32px; + flex-wrap: wrap; + background: var(--card); + border: 1px solid var(--border); + border-radius: 12px; + } + .qs-group { display: flex; flex-direction: column; gap: 8px; flex: 1; min-width: 260px; } + .qs-label { + font-family: 'DM Mono', monospace; + font-size: 11px; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--muted); + } + .qs-term-wrap { margin-bottom: 0; } + .qs-term-wrap .tier-seg { padding: 10px 8px; } + .qs-term-wrap .tier-name { font-size: 12px; } + .qs-term-wrap .tier-price { display: none; } + .qs-right { + display: flex; + flex-direction: column; + gap: 12px; + padding-top: 19px; + } + .qs-toggle-row { + display: flex; + align-items: center; + gap: 9px; + cursor: pointer; + user-select: none; + } + .qs-toggle-row input[type=checkbox] { accent-color: var(--accent); width: 16px; height: 16px; cursor: pointer; } + .qs-toggle-label { + font-family: 'DM Mono', monospace; + font-size: 13px; + color: var(--muted); + letter-spacing: 0.04em; + } + .qs-fee-row { + display: flex; + flex-direction: column; + gap: 6px; + } + .qs-fee-header { + display: flex; + align-items: center; + gap: 10px; + } + .qs-fee-waive { margin-left: 4px; } + .qs-fee-waive:has(input:disabled) { opacity: 0.5; cursor: default; } + .qs-fee-input:disabled { opacity: 0.4; cursor: not-allowed; } + .qs-fee-label { + font-family: 'DM Mono', monospace; + font-size: 13px; + color: var(--muted); + white-space: nowrap; + letter-spacing: 0.04em; + } + .qs-fee-input-wrap { + display: flex; + align-items: center; + background: var(--card); + border: 1px solid var(--border); + border-radius: 4px; + overflow: hidden; + } + .qs-fee-dollar { + padding: 6px 8px; + font-family: 'DM Mono', monospace; + font-size: 14px; + color: var(--muted); + background: var(--card); + border-right: 1px solid var(--border); + } + .qs-fee-input { + background: var(--card); + border: none; + color: var(--ink); + font-family: 'DM Mono', monospace; + font-size: 14px; + width: 90px; + text-align: right; + padding: 6px 10px; + outline: none; + } + .qs-fee-input::-webkit-inner-spin-button, + .qs-fee-input::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; } + .qs-fee-input[type=number] { -moz-appearance: textfield; } + + /* ── INLINE-STYLE REPLACEMENT CLASSES ─────────────────────────── + These replace presentational style="" attributes that previously + bypassed the design token system. All colours use tokens. + ─────────────────────────────────────────────────────────────── */ + + /* Section I — admin fee display row */ + .admin-fee-header { display: flex; align-items: baseline; gap: 12px; margin-bottom: 6px; } + .admin-fee-title { font-family: 'Poppins', sans-serif; font-weight: 600; font-size: 22px; } + .admin-fee-val { font-family: 'DM Mono', monospace; font-size: 22px; color: var(--accent); } + .admin-fee-sub { font-size: 12px; color: var(--muted); margin-bottom: 12px; } + .floor-note { font-size: 13px; color: var(--muted); margin-top: 8px; margin-bottom: 16px; font-family: 'DM Mono', monospace; } + + /* Sidebar sub-line rows (users/endpoints/admin breakdown text) */ + .sl-sub { font-size: 13px; color: var(--muted); font-family: 'DM Mono', monospace; padding: 0 0 6px; } + + /* Per-user cost section */ + .per-user-cost-sub { font-size: 10px; opacity: 0.6; font-weight: 400; } + .sidebar-note-mono { font-size: 12px; padding: 2px 0 6px; font-family: 'DM Mono', monospace; } + + /* VS Comparison block */ + .vs-comparison-wrap { margin-top: 16px; border-top: 1px solid var(--border); padding-top: 14px; } + .vs-inline-icon { margin-right: 5px; vertical-align: middle; } + .vs-svs-label { font-size: 15px; color: var(--ink); font-weight: 600; } + .vs-val-accent { color: var(--accent); font-weight: 600; } + .vs-td-muted { color: var(--muted); } + .vs-td-icon { margin-right: 5px; opacity: 0.6; vertical-align: middle; } + .vs-footnote { font-size: 13px; color: var(--muted); margin-top: 8px; line-height: 1.6; text-align: center; } + + /* Side note icons and savings highlight */ + .note-icon { margin-right: 6px; vertical-align: middle; flex-shrink: 0; } + .savings-amount { color: var(--green); } + .sl-otf-waived > span:first-child { color: var(--green); } + .sl-otf-waived .val { color: var(--green); font-size: 12px; letter-spacing: 0.04em; } + + /* Nudge banner internal flex rows */ + .nudge-header-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; } + .nudge-nav-group { display: flex; gap: 4px; } + + /* VoIP savings prompt */ + .savings-prompt { font-size: 13px; color: var(--muted); margin-top: 8px; } + + /* ── SIDEBAR UTILITY CLASSES ───────────────────────────────────── + .sl-muted — de-emphasised row labels/values + .sl-discount-val — green discount amount + .sl-hst-val — muted HST amount + ─────────────────────────────────────────────────────────────── */ + .sl-muted { color: var(--muted) !important; font-size: 13px; } + .sl-discount-val { color: var(--green) !important; font-size: 13px; } + .sl-hst-val { color: var(--muted) !important; font-size: 13px; } + .sidebar-line-discount { border-bottom-style: dashed; opacity: 0.8; } + .sidebar-line-hst { border-color: transparent; padding-top: 4px; padding-bottom: 4px; } + .sidebar-line-total { font-weight: 600; border-top: 1px solid var(--border); margin-top: 4px; padding-top: 10px; } + .sl-hst-toggle { + display: flex; align-items: center; gap: 6px; + font-size: 12px; color: var(--muted); + font-family: 'DM Mono', monospace; letter-spacing: 0.04em; + cursor: pointer; user-select: none; + margin: 6px 0 4px; + } + .sl-hst-toggle input { accent-color: var(--accent); cursor: pointer; } + + /* ── VS COMPARISON CSS CLASSES (replace inline styles) ────────── + .vs-save-green — green "YOU SAVE" row background + .vs-save-amber — amber "Costs more" row background + .vs-val-green — green text for savings value/label + .vs-val-amber — amber text for "costs more" value/label + ─────────────────────────────────────────────────────────────── */ + .vs-save-green { background: #1a2e20; } + .vs-save-amber { background: #2e1e0a; } + .vs-val-green { color: var(--green) !important; } + .vs-val-amber { color: var(--amber) !important; } + + /* ── SECTION CARD HOVER / OPEN POLISH ─────────────────────────── + Subtle left accent glow on hover; stronger treatment when open. + ─────────────────────────────────────────────────────────────── */ + .section { + transition: border-color 0.2s, box-shadow 0.2s; + } + .section:hover { + border-color: rgba(45,122,168,0.35); + box-shadow: -3px 0 0 0 rgba(45,122,168,0.4); + } + .sec-open { + border-color: rgba(45,122,168,0.5) !important; + box-shadow: -3px 0 0 0 rgba(45,122,168,0.7) !important; + } + + /* ── ADDON ROW SELECTED — stronger check indicator ────────────── + .selected gets a more prominent border + check indicator via + the checkbox's native accent-color. No pseudo-element needed. + ─────────────────────────────────────────────────────────────── */ + .addon-row.selected { + background: #1a2a38; + border-color: var(--accent); + box-shadow: inset 3px 0 0 0 var(--accent); + } + .addon-row.selected .addon-name { color: var(--ink); } + .addon-row.selected .addon-price { color: #62c5f0; } + + /* ── EXPORT BUTTONS ───────────────────────────────────────────── + Primary CTA: Print / Save PDF + Secondary: Export JSON + Copy + ─────────────────────────────────────────────────────────────── */ + .export-wrap { + padding: 20px 24px 24px; + background: var(--card); + display: flex; + flex-direction: column; + gap: 8px; + } + .btn-export { + width: 100%; + background: var(--accent); + color: #fff; + border: none; + border-radius: 6px; + padding: 15px 16px; + font-family: 'DM Mono', monospace; + font-size: 14px; + letter-spacing: 0.07em; + text-transform: uppercase; + cursor: pointer; + transition: background 0.15s, transform 0.1s; + display: flex; + align-items: center; + justify-content: center; + } + .btn-export:hover { background: #3a8fc4; } + .btn-export:active { transform: scale(0.98); } + .btn-export-secondary { + background: transparent; + border: 1px solid var(--border); + color: var(--muted); + font-size: 13px; + padding: 11px 16px; + } + .btn-export-secondary:hover { background: var(--card); border-color: var(--accent); color: var(--ink); } + + /* ── VOIP PHONE BILL SAVINGS ESTIMATOR (Section VI) ──────────── + Optional input: current monthly phone bill. + updateSavings(q) compares against voipTotal and shows green + savings message or amber warning if VoIP costs more. + On mobile stacks vertically (flex-direction:column). + ─────────────────────────────────────────────────────────────── */ + .savings-input-row { + display: flex; + align-items: center; + gap: 12px; + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid var(--border); + } + .savings-input-row label { + font-size: 14px; + color: var(--muted); + flex: 1; + } + .savings-input-row input { + background: var(--card); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--ink); + font-family: 'DM Mono', monospace; + font-size: 18px; + width: 120px; + text-align: center; + padding: 7px; + outline: none; + } + .savings-result { + margin-top: 12px; + background: #0d2016; + border: 1px solid #1e4a30; + border-radius: 6px; + padding: 12px 16px; + font-family: 'DM Mono', monospace; + font-size: 14px; + color: var(--green); + line-height: 1.65; + } + + /* ── BOTTOM PITCH BANNER ──────────────────────────────────────── + 4-column grid (2-col on tablet/mobile) outside the .outer grid. + .pitch-inner has margin-left:96px to align with section cards. + Columns: Security-First | Ottawa-Based | Flat-Rate | Scales With You + .pitch-footer — green strip at bottom with tagline. + Icons are inline SVG (FA Free 6.5.0 paths), accent blue. + ─────────────────────────────────────────────────────────────── */ + .pitch-wrap { + width: 100%; + padding: 0; + margin: 0; + } + .pitch-inner { + margin-left: 0; + background: var(--card); + border: none; + border-top: 1px solid var(--border); + border-radius: 0; + overflow: hidden; + } + .pitch-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + } + .pitch-item { + padding: 30px 24px; + border-right: 1px solid var(--border); + } + .pitch-item:last-child { border-right: none; } + .pitch-icon { font-size: 20px; margin-bottom: 12px; color: var(--accent); } + .pitch-title { font-family: 'Poppins', sans-serif; font-weight: 600; font-size: 16px; margin-bottom: 8px; } + .pitch-desc { font-size: 14px; color: var(--muted); line-height: 1.7; } + .pitch-footer { + background: #1a2e20; + border-top: 1px solid #1e4a30; + padding: 16px 32px; + font-family: 'DM Mono', monospace; + font-size: 13px; + color: var(--green); + letter-spacing: 0.05em; + text-align: center; + } + + /* ═══════════════════════════════════════════════════════ + RESPONSIVE BREAKPOINTS — MOBILE FIRST + ───────────────────────────────────────────────────── + ≤1100px tablet portrait — single col, sidebar hidden, + mobile pill + panel shown + ≤ 900px small tablet — tighter spacing, smaller numerals + ≤ 600px phone portrait — no numeral gutter, section-num + inline, section-header grid layout + ≤ 780px landscape — restore 2-col, sticky sidebar + ───────────────────────────────────────────────────── + MOBILE PANEL ARCHITECTURE: + Desktop — .side-col visible, .mobile-quote-panel hidden + ≤1100px — .side-col hidden (display:none), pill shown + Pill tap — openMobilePanel() adds .open to #mobileQuotePanel + Panel contains static duplicate sidebar with _m ID suffix + update() syncs all _m elements via syncEl/syncClass/syncStyle + DO NOT add display:none to .side-col at desktop level or the + sidebar will disappear on desktop after panel is opened. + ═══════════════════════════════════════════════════════ */ + /* ═══════════════════════════════════════════════════════ + RESPONSIVE — MOBILE FIRST + Breakpoints: + < 600px — phone portrait + 600–900px — phone landscape / small tablet + 900–1100px — tablet portrait + > 1100px — desktop (base styles apply) + ═══════════════════════════════════════════════════════ */ + + /* ── TABLET PORTRAIT (≤ 1100px) ── */ + @media (max-width: 1100px) { + .outer { + grid-template-columns: 1fr; + gap: 0; + padding: 36px clamp(16px,3vw,32px) 60px; + } + .side-col { + position: static; + margin-top: 0; + } + .sidebar { + margin-top: 0; + border-radius: 12px; + } + /* Sidebar sits ABOVE the form on tablet/mobile */ + .main-col { order: 2; } + .side-col { order: 1; } + + /* Narrower numeral gutter */ + .section { margin-left: 64px; } + .section-num { left: -64px; width: 56px; font-size: 52px; } + .client-bar { padding: 24px 0 24px 64px; } + .quote-settings-bar { margin-left: 64px; padding: 18px 22px 20px 22px; gap: 20px; } + .top-bar-logo { margin-left: 38px; } + .pitch-inner { margin-left: 0; } + + .pitch-grid { grid-template-columns: repeat(2, 1fr); } + .pitch-item:nth-child(2) { border-right: none; } + .pitch-item:nth-child(3) { border-top: 1px solid var(--border); } + .pitch-item:nth-child(4) { border-top: 1px solid var(--border); border-right: none; } + } + + /* ── SMALL TABLET / LANDSCAPE PHONE (≤ 900px) ── */ + @media (max-width: 900px) { + .top-bar { padding: 12px 0; } + .outer { padding: 28px clamp(14px,3vw,28px) 48px; } + + .section { margin-left: 52px; padding: 24px 24px 28px; } + .section-num { left: -52px; width: 44px; font-size: 42px; top: 24px; } + .top-bar-logo { margin-left: 26px; } + .client-bar { padding: 20px 0 20px 52px; } + .quote-settings-bar { margin-left: 52px; padding: 16px 18px 16px 18px; gap: 16px; } + .pitch-wrap { padding: 0; } + .pitch-inner { margin-left: 0; } + + .section-header { + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; + margin-bottom: 24px; + } + .section-title-block { order: 2; flex: 0 0 100%; } + .sec-summary-badge { order: 1; margin-left: auto; } + .sec-chevron { order: 1; } + .section-title { font-size: 20px; } + + .main-col { gap: 20px; } + .main-col > .section:first-of-type { margin-top: 16px; } + } + + /* ── PHONE (≤ 600px) ── */ + @media (max-width: 600px) { + /* No gutter — numerals tuck inline */ + .outer { padding: 20px 16px 40px; } + + /* Collapse numeral gutter entirely on phones */ + .top-bar-logo { margin-left: 0; } + .section { + margin-left: 0; + padding: 20px 18px 24px; + border-radius: 10px; + } + .section-num { + position: static; + display: inline-block; + font-size: 20px; + width: auto; + color: var(--accent); + background: transparent; + border: none; + border-radius: 0; + padding: 0; + margin-bottom: 8px; + letter-spacing: 0.06em; + line-height: 1; + text-align: left; + font-family: 'Cinzel', serif; + opacity: 0.85; + } + /* ── Mobile section header: top row = numeral + right-side controls + bottom row = title block full width ── */ + .section-header { + display: grid; + /* Col 1: numeral (auto width) | Col 2: badge+chevron (auto) */ + grid-template-columns: auto 1fr auto; + grid-template-rows: auto auto; + column-gap: 10px; + row-gap: 8px; + margin-bottom: 20px; + align-items: center; + } + /* Numeral: top-left */ + .section-num { + grid-column: 1; + grid-row: 1; + } + /* Summary badge: top-middle (expands to fill) — right-aligned */ + .sec-summary-badge { + grid-column: 2; + grid-row: 1; + text-align: right; + justify-self: end; + white-space: nowrap; + font-size: 12px; + margin-left: 0; + order: 0; + } + /* Chevron: top-right with pill background */ + .sec-chevron { + grid-column: 3; + grid-row: 1; + padding: 0; + width: 36px; + height: 36px; + display: flex !important; + align-items: center; + justify-content: center; + background: rgba(255,255,255,0.07); + border-radius: 6px; + color: var(--muted); + flex-shrink: 0; + } + .sec-chevron svg { width: 16px; height: 16px; stroke-width: 3; } + /* Title block: second row, full width */ + .section-title-block { + grid-column: 1 / -1; + grid-row: 2; + width: 100%; + min-width: 0; + order: 0; + flex: initial; + } + .section-title { font-size: 18px; } + + .client-bar { padding: 20px 0 20px 0; } + .client-input { font-size: 22px; max-width: 100%; } + .quote-settings-bar { margin-left: 0; padding: 14px 16px 16px 16px; flex-direction: column; gap: 12px; } + .qs-group { min-width: 0; } + .qs-right { padding-top: 0; } + + .main-col { gap: 16px; } + .main-col > .section:first-of-type { margin-top: 8px; } + + /* Pill toggle — stack vertically on tiny screens */ + .pill-toggle { + grid-template-columns: 1fr; + } + .pill-toggle label { border-right: none; border-bottom: 1px solid var(--border); } + .pill-toggle label:last-child { border-bottom: none; } + + /* Tier segments — keep 3-col but tighter */ + .tier-seg { padding: 12px 6px; } + .tier-seg .tier-price { font-size: 18px; } + .tier-seg .tier-name { font-size: 11px; } + + /* Input rows — stack label above input */ + .input-row { + flex-direction: column; + align-items: flex-start; + gap: 12px; + } + .num-stepper { width: 100%; } + .num-input { width: 100%; font-size: 20px; padding: 12px; flex: 1; } + .step-btn { width: 48px; font-size: 22px; } + + /* Addon rows — tighter */ + .addon-row { padding: 12px 10px; gap: 10px; } + .addon-name { font-size: 14px; } + .addon-desc { font-size: 12px; } + + /* Collapsible */ + .collapsible-body { padding: 12px 0 16px 16px; } + + /* Feature cards — single col already, just tighter */ + .feature-card { padding: 12px 14px; } + + /* Savings row — stack */ + .savings-input-row { flex-direction: column; align-items: flex-start; gap: 8px; } + .savings-input-row input { width: 100%; } + + /* Sidebar */ + .sidebar-body { padding: 16px; } + .sidebar-header { padding: 16px 18px; } + .sidebar-mrr { font-size: 38px; } + .sidebar-client { font-size: 18px; } + + /* VS table — tighten */ + .vs-table td { padding: 5px 3px; font-size: 13px; } + + /* Pitch footer */ + .pitch-wrap { padding: 0; } + .pitch-inner { margin-left: 0; border-radius: 0; } + .pitch-grid { grid-template-columns: 1fr 1fr; } + .pitch-item { padding: 20px 16px; } + .pitch-item:nth-child(2) { border-right: none; } + .pitch-item:nth-child(3) { border-top: 1px solid var(--border); } + .pitch-item:nth-child(4) { border-top: 1px solid var(--border); border-right: none; } + .pitch-title { font-size: 14px; } + .pitch-desc { font-size: 13px; } + .pitch-footer { padding: 14px 16px; font-size: 12px; } + + /* Nudge banner */ + .nudge-banner { padding: 14px 16px; font-size: 14px; } + .export-wrap { padding: 16px 16px 20px; } + + /* Fee table */ + .fee-table td { padding: 7px 0; font-size: 13px; } + } + + /* ── LANDSCAPE PHONE (≤ 780px, orientation: landscape) ── */ + @media (max-width: 780px) and (orientation: landscape) { + .outer { + grid-template-columns: 1fr 1fr; + gap: 24px; + padding: 20px 20px 40px; + align-items: start; + } + .main-col { order: 1; gap: 16px; } + .side-col { + order: 2; + position: sticky; + top: 60px; + align-self: start; + } + .section { margin-left: 44px; padding: 18px 20px 22px; } + .section-num { left: -44px; width: 38px; font-size: 36px; top: 18px; position: absolute; } + .client-bar { padding: 16px 0 16px 44px; } + .sidebar { margin-top: 0; } + .sidebar-mrr { font-size: 32px; } + .pitch-grid { grid-template-columns: repeat(2, 1fr); } + .pitch-inner { margin-left: 0; } + .pitch-wrap { padding: 0; } + } + + + /* ── MOBILE-ONLY ELEMENTS — hidden at desktop baseline ───────── + MUST be display:none here (outside any media query) so that + the panel doesn't render on top of desktop layout. + The @media (max-width:1100px) block below overrides to display:flex. + ─────────────────────────────────────────────────────────────── */ + .mobile-quote-pill { display: none; } + .mobile-quote-panel { display: none; } + + /* ═══════════════════════════════════════ + MOBILE QUOTE PILL + FULL-SCREEN PANEL + ═══════════════════════════════════════ */ + + + @media (max-width: 1100px) { + /* Hide the static sidebar entirely on mobile/tablet */ + .side-col { display: none; } + + /* Show the floating pill */ + .mobile-quote-pill { + display: flex; + align-items: center; + gap: 10px; + position: fixed; + top: 62px; + right: 14px; + z-index: 200; + background: var(--accent); + color: #fff; + border-radius: 50px; + padding: 10px 18px 10px 14px; + cursor: pointer; + box-shadow: 0 4px 20px rgba(0,0,0,0.45); + border: none; + font-family: 'DM Mono', monospace; + font-size: 15px; + font-weight: 500; + letter-spacing: 0.04em; + transition: background 0.15s, transform 0.15s; + user-select: none; + -webkit-tap-highlight-color: transparent; + } + .mobile-quote-pill:active { transform: scale(0.96); } + .mobile-quote-pill:hover { background: #3a8fc4; } + .mobile-pill-icon { + width: 28px; + height: 28px; + background: rgba(255,255,255,0.2); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + } + .mobile-pill-mrr { + font-family: 'DM Mono', monospace; + font-size: 16px; + font-weight: 500; + line-height: 1; + } + .mobile-pill-label { + font-size: 10px; + opacity: 0.75; + letter-spacing: 0.1em; + text-transform: uppercase; + line-height: 1; + margin-top: 2px; + } + + /* Full-screen overlay panel */ + .mobile-quote-panel { + position: fixed; + inset: 0; + z-index: 300; + display: flex; + flex-direction: column; + pointer-events: none; + opacity: 0; + transition: opacity 0.25s ease; + } + .mobile-quote-panel.open { + pointer-events: all; + opacity: 1; + } + + /* Dark backdrop */ + .mobile-panel-backdrop { + position: absolute; + inset: 0; + background: rgba(0,0,0,0.65); + backdrop-filter: blur(3px); + -webkit-backdrop-filter: blur(3px); + } + + /* Slide-up sheet */ + .mobile-panel-sheet { + position: absolute; + bottom: 0; + left: 0; + right: 0; + max-height: 92vh; + background: var(--card); + border-radius: 20px 20px 0 0; + overflow-y: auto; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; + transform: translateY(100%); + transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1); + will-change: transform; + border-top: 1px solid var(--border); + } + .mobile-quote-panel.open .mobile-panel-sheet { + transform: translateY(0); + } + + /* Drag handle */ + .mobile-panel-handle { + width: 40px; + height: 4px; + background: var(--border); + border-radius: 2px; + margin: 14px auto 0; + flex-shrink: 0; + } + + /* Close button row */ + .mobile-panel-close-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px 12px; + border-bottom: 1px solid var(--border); + } + .mobile-panel-close-title { + font-family: 'DM Mono', monospace; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--muted); + } + .mobile-panel-close-btn { + background: var(--border); + border: none; + color: var(--ink); + border-radius: 50%; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + font-size: 20px; + line-height: 1; + flex-shrink: 0; + -webkit-tap-highlight-color: transparent; + transition: background 0.15s; + } + .mobile-panel-close-btn:active { background: var(--muted); } + + /* Sidebar inside the mobile sheet — strip all desktop positioning */ + .mobile-panel-sheet .sidebar { + margin-top: 0 !important; + border-radius: 0 !important; + border: none !important; + box-shadow: none !important; + overflow: visible !important; + } + /* Sidebar-header inside panel — hide it, panel has its own header row */ + .mobile-panel-sheet .sidebar-header { + display: none !important; + } + .mobile-panel-sheet .sidebar-body { + padding-top: 4px !important; + } + } + + /* Landscape phone — pill stays, sheet max-height smaller */ + @media (max-width: 780px) and (orientation: landscape) { + .mobile-panel-sheet { max-height: 88vh; } + } + + /* ═══════════════════════════════════════════════════════ + PRINT / PDF EXPORT (Export A) + window.print() triggers this via Print / Save PDF button. + Goals: + - Clean, branded quote document + - Hide all interactive controls + - Force all sections expanded (body shown) + - No background colours that waste ink (except header) + - Page-break control so summary never splits + ═══════════════════════════════════════════════════════ */ + @media print { + /* ── Force light background on body ── */ + body { background: #fff !important; color: #1a1a1a !important; font-size: 13px; } + + /* ── Hide interactive & mobile-only elements ── */ + .mobile-quote-pill, + .mobile-quote-panel, + .step-btn, + .collapsible-header, + .sec-chevron, + .section-toggle, + .tier-seg-wrap, + .pill-toggle, + .addon-row input[type=checkbox], + .savings-input-row, + .export-wrap, + .nudge-banner, + .pitch-wrap, + .quote-settings-bar, + .section-header .section-toggle { pointer-events: none; } + + .mobile-quote-pill { display: none !important; } + .mobile-quote-panel { display: none !important; } + .export-wrap { display: none !important; } + .nudge-banner { display: none !important; } + .theme-toggle-btn { display: none !important; } + .pitch-wrap { display: none !important; } + .step-btn { display: none !important; } + .collapsible-header { display: none !important; } + .sec-chevron { display: none !important; } + .sec-summary-badge { display: none !important; } + .quote-settings-bar { display: none !important; } + .section-badge { display: none !important; } + #savingsPrompt { display: none !important; } + + /* ── Show ALL section bodies (force expand) ── */ + .section-body { display: block !important; } + .collapsible-body { max-height: none !important; opacity: 1 !important; overflow: visible !important; } + + /* ── Reset layout to single column ── */ + .outer { + display: block !important; + padding: 0 !important; + max-width: 100% !important; + } + .main-col, .side-col { width: 100% !important; position: static !important; } + + /* ── Top bar: keep accent, reduce height ── */ + .top-bar { + position: static !important; + padding: 10px 20px !important; + border-bottom: 2px solid #2d7aa8 !important; + background: #fff !important; + } + .top-bar-inner { padding: 0 !important; } + .top-bar-right { color: #555 !important; } + + /* ── Section cards: clean borders, no dark bg ── */ + .section { + background: #fff !important; + border: 1px solid #ccc !important; + box-shadow: none !important; + margin-left: 0 !important; + page-break-inside: avoid; + break-inside: avoid; + padding: 16px 20px !important; + margin-bottom: 12px !important; + } + .section-num { color: #bbb !important; } + .section-title { font-size: 16px !important; } + + /* ── Sidebar: show inline after sections, styled for print ── */ + .sidebar { + background: #fff !important; + border: 2px solid #2d7aa8 !important; + border-radius: 6px !important; + margin: 16px 0 !important; + page-break-inside: avoid; + break-inside: avoid; + } + .sidebar-header { background: #2d7aa8 !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; } + .sidebar-mrr { font-size: 36px !important; color: #1a1a1a !important; } + .sidebar-line { color: #444 !important; border-bottom-color: #ddd !important; } + .sidebar-line .val { color: #1a1a1a !important; } + + /* ── VS comparison: clean for print ── */ + .vs-save-green { background: #e8f5e9 !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; } + .vs-save-amber { background: #fff8e1 !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; } + + /* ── Feature cards: minimal ── */ + .feature-card { background: #f9f9f9 !important; border-color: #ddd !important; } + .feature-card-grid { grid-template-columns: 1fr 1fr !important; gap: 6px !important; } + + /* ── Addon rows ── */ + .addon-row { border-color: #ddd !important; } + .addon-row.selected { background: #e8f4fb !important; border-color: #2d7aa8 !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; } + + /* ── Callout boxes ── */ + .callout-green { background: #f0faf4 !important; border-color: #3ab870 !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; } + .callout-red { background: #fff0f0 !important; border-color: #c4526a !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; } + + /* ── Progress bar ── */ + .progress-fill { -webkit-print-color-adjust: exact; print-color-adjust: exact; } + + /* ── Print footer ── */ + .pitch-footer { display: none !important; } + + /* ── Page break: force summary sidebar to start fresh ── */ + .side-col { page-break-before: always; break-before: always; } + + /* ── Input fields: show values as static text ── */ + .num-input, .qs-fee-input { + border: none !important; + background: transparent !important; + font-weight: 700 !important; + } + .client-input { + border: none !important; + background: transparent !important; + } + + /* ── Print footer note ── */ + body::after { + content: 'Prepared by Silicon Valley Services (SVS) MSP · Ottawa, Ontario · This quote is valid for 30 days from date of issue. Questions? Contact your SVS account representative.'; + display: block; + font-size: 11px; + color: #888; + border-top: 1px solid #ddd; + padding-top: 10px; + margin-top: 20px; + font-family: 'DM Mono', monospace; + } + } diff --git a/SVS-MSP-Calculator.html b/SVS-MSP-Calculator.html new file mode 100644 index 0000000..e216aba --- /dev/null +++ b/SVS-MSP-Calculator.html @@ -0,0 +1,949 @@ + + + + + +SVS MSP — Live Quote Calculator + + + + + + + + + + +
+
+
+
+
+ Quote Summary + +
+ +
+ + + +
+
+
+ + + + +
+
+ + +
+ SVS-00000000-0000
+ +
+
+
+ + +
+ +
+ + +
+
Prepared for
+ +
+ + +
+
+
Contract Term
+
+ + + + + + +
+
+
+
+
+ + +
+
+ $ + +
+
+
+
+ + +
+
+
I
+
+
Environment Management Site Admin Fee
+
Flat monthly fee covering tenant, network, documentation & vendor management
+ Flat Monthly / Environment +
+ +
+
+
+
+ +
+ Site Admin Fee + $150/mo +
+
Calculated from services below · floor $150/mo
+ +
+
+ Minimum Engagement Threshold + $0 / $650 +
+
+
+
+
+
+ + + + + + +
Base Site Admin
Total Admin Fee
+ + +
+ + What's Covered by the Admin Fee +
+
+
+
Tenant & Identity Management
Microsoft 365 / Entra ID tenant administration, user lifecycle, MFA enforcement, and conditional access policies.
+
Network & Infrastructure Oversight
Firewall configuration reviews, DNS management, VLAN segmentation oversight, and network performance monitoring.
+
Documentation & Runbooks
Living IT documentation, network diagrams, asset registers, and runbooks updated continuously in your client portal.
+
Vendor Management
Single point of contact for all technology vendors — ISPs, software, hardware, and cloud providers.
+
Security Posture Management
Monthly security reviews, vulnerability scan oversight, patch compliance reporting, and security baseline enforcement.
+
Reporting & QBRs
Monthly infrastructure health reports, ticket trend analysis, and quarterly business reviews with your account manager.
+
Cloud Governance
License optimization, cloud spend visibility, Microsoft Secure Score improvement, and policy compliance alignment.
+
Incident Coordination
Major incident management, root cause analysis, and coordinated response across all your managed services.
+
Backup Monitoring
Daily backup job verification, restore testing schedules, retention compliance checks, and failure alerting — separate from the Bare Metal Backup add-on.
+
+
+ +
+
+
+ + +
+
+
II
+
+
User Package
+
Per-user monthly services — identity, email, security & helpdesk
+ Per User / Month +
+ +
+
+
+
+ + +
+ + + + +
+ +
+ Bundled M365 Business Premium saves up to $15/user/mo vs buying retail — runs ~$29–36/mo CAD on its own +
+ + + +
+ + What's Included in This Package +
+
+
    +
  • Microsoft 365 Business Premium (M365 tier) — Word, Excel, PowerPoint, Teams, Exchange
  • +
  • Entra ID & MFA — identity protection, conditional access, and SSO
  • +
  • Microsoft Defender for Business — endpoint + email threat protection
  • +
  • Helpdesk support (business hours) — tickets, remote sessions, escalations
  • +
  • Onboarding & offboarding — provisioning, access revocation, equipment checklists
  • +
  • Security awareness training (SAT) — phishing simulations & training modules
  • +
  • User-level documentation — accounts, devices, access tracked per user
  • +
+
+ +
+
+
Number of Users
+
+
+ + + +
+
+ + +
+ + Per-User Add-Ons +
+ Extended Hours + 1Password + INKY Pro + Zero Trust +
+
+
+
+ + + + +
+
+ +
+
+
+ + +
+
+
III
+
+
Endpoint Package
+
Per-device managed protection — workstations & laptops
+ $35 / Endpoint / Month +
+ +
+
+
+
+ + +
+ + What's Included in This Package +
+
+
    +
  • RMM agent — remote monitoring, patching & automated remediation
  • +
  • Huntress EDR — 24/7 SOC-backed threat hunting & incident response
  • +
  • Patch management — OS & third-party software, tested & staged rollouts
  • +
  • Disk encryption enforcement — BitLocker management & key escrow
  • +
  • Asset inventory — hardware specs, software, warranties tracked per device
  • +
  • Cyber warranty — up to $1M coverage backed by our toolstack compliance
  • +
+
+ +
+
+
Number of Endpoints
+
Workstations, laptops — per managed device
+
+
+ + + +
+
+ + +
+ + Per-Endpoint Add-Ons +
+ Bare Metal Backup + USB Blocking +
+
+
+
+ + +
+
+ +
+
+
+ + +
+
+
IV
+
+
Server Management
+
Dedicated management for physical & virtual servers
+ $120 / Server / Month +
+ +
+
+ +
+ + +
+
+
V
+
+
Zero Trust Networking HaaS
+
Cytracom-powered ZT network access — seats & managed hardware as a service
+ Per User + Per Device / Month +
+ +
+
+ +
+ + +
+
+
VI
+
+
VoIP / Unified Communications UCaaS
+
United Cloud-powered business phone — seats, features & optional desk phones
+ Per Seat / Month +
+ +
+
+ +
+ +
+ + + +
+ +
+ +
+ + +
+
+
+
+
+
Security-First MSP
+
Every engagement is built on a security baseline — EDR, MFA, patch management, and cyber warranty included.
+
+
+
+
Ottawa-Based Team
+
Local presence, Canadian data sovereignty, and an account team that knows your business and your region.
+
+
+
+
Flat-Rate, No Surprises
+
Predictable monthly billing with no per-ticket charges — aligned incentives mean we fix things right the first time.
+
+
+
+
Scales With You
+
Add users, endpoints, servers, ZT networking, or VoIP as you grow — one vendor, one invoice, one relationship.
+
+
+ +
+
+ + + + + diff --git a/SVS-MSP-Calculator.js b/SVS-MSP-Calculator.js new file mode 100644 index 0000000..ebe8950 --- /dev/null +++ b/SVS-MSP-Calculator.js @@ -0,0 +1,1197 @@ +// ── PRICING CONSTANTS ─────────────────────────────────────────── +// All rates in CAD. Edit here — calcQuote() reads these only. +// ADMIN_FEE_FLOOR = minimum site admin fee regardless of seat count +// ADMIN_FEE_MINIMUM = engagement threshold; admin = max(FLOOR, MIN-subtotal) +// ADMIN_FEE_ZT = supplement added to admin when ZT is active +// TOOL_COST_* = internal cost estimates (VS comparison only, not billed) +// IT_SALARY_1/5 = Ottawa benchmark salaries for VS comparison +// ───────────────────────────────────────────────────────────────── +const ADMIN_FEE_FLOOR = 150; +const ADMIN_FEE_MINIMUM = 650; +const ADMIN_FEE_ZT = 250; +const VOIP_RATES = { basic: 28, standard: 35, premium: 45 }; +const VOIP_PHONE_RATE = 15; +const VOIP_FAX_RATE = 10; +const TOOL_COST_PER_USER = 42; +const TOOL_COST_PER_ENDPOINT = 23; +const TOOL_COST_MIN = 650; +const IT_SALARY_1 = 85000; +const IT_SALARY_5 = 420000; +// CONTRACT_DISCOUNT: discount applied to MRR based on contract length +// m2m = month-to-month (no discount), 12mo = 3% off, 24mo = 5% off +const CONTRACT_DISCOUNT = { 'm2m': 0, '12mo': 0.03, '24mo': 0.05 }; +const HST_RATE = 0.13; // Ontario HST 13% + +// --- CALC --- +// ── calcQuote() ───────────────────────────────────────────────── +// Single source of truth for all pricing. +// Returns a plain object with every calculated value. +// Called once per update() cycle; result q passed to all sub-functions. +// NEVER writes to DOM — pure calculation only. +function calcQuote() { + const byol = document.getElementById('rateBYOL')?.checked || false; + const users = parseInt(document.getElementById('userCount')?.value) || 0; + const endpoints = parseInt(document.getElementById('endpointCount')?.value) || 0; + const servers = parseInt(document.getElementById('serverCount')?.value) || 0; + const addExtHours= document.getElementById('addExtHours')?.checked || false; + const addPWM = document.getElementById('addPWM')?.checked || false; + const addINKY = document.getElementById('addINKY')?.checked || false; + const addZT = document.getElementById('addZT')?.checked || false; + const addUSB = document.getElementById('addUSB')?.checked || false; + const addBMB = document.getElementById('addBMB')?.checked || false; + const ztSeats = parseInt(document.getElementById('ztNetSeats')?.value) || 0; + const ztRouters = parseInt(document.getElementById('ztNetRouters')?.value) || 0; + const voipTier = (document.querySelector('input[name="voipTier"]:checked') || {}).value || 'basic'; + const voipSeats = parseInt(document.getElementById('voipSeats')?.value) || 0; + const addVoipPhone = document.getElementById('addVoipPhone')?.checked || false; + const addVoipFax = document.getElementById('addVoipFax')?.checked || false; + const clientName = document.getElementById('clientName')?.value || ''; + const contractTerm = (document.querySelector('input[name="contractTerm"]:checked') || {}).value || 'm2m'; + const hstEnabled = document.getElementById('hstToggle')?.checked || false; + const oneTimeFee = parseFloat(document.getElementById('oneTimeFee')?.value) || 0; + const ztActive = addZT || ztSeats > 0; + + const baseUserRate = byol ? 110 : 130; + const addonRate = (addExtHours ? 25 : 0) + (addPWM ? 9 : 0) + (addINKY ? 5 : 0) + (addZT ? 55 : 0); + const totalUserRate= baseUserRate + addonRate; + + const userBase = users * baseUserRate; + const userExt = addExtHours ? users * 25 : 0; + const userPWM = addPWM ? users * 9 : 0; + const userINKY = addINKY ? users * 5 : 0; + const userZT = addZT ? users * 55 : 0; + const userTotal = userBase + userExt + userPWM + userINKY + userZT; + + const endpointBase = endpoints * 35; + const serverBase = servers * 120; + const endpointUSB = addUSB ? endpoints * 4 : 0; + const endpointBMB = addBMB ? endpoints * 25 : 0; + const endpointTotal= endpointBase + serverBase + endpointUSB + endpointBMB; + + const baseSubtotal = userBase + endpointBase + serverBase; + const siteAdminBase = Math.max(ADMIN_FEE_FLOOR, ADMIN_FEE_MINIMUM - baseSubtotal); + const admin1PWM = addPWM ? Math.round(userPWM * 0.10) : 0; + const adminFeeNet = siteAdminBase + (ztActive ? ADMIN_FEE_ZT : 0) + admin1PWM; + + const ztNetSeats = ztSeats * 25; + const ztNetRouters = ztRouters * 100; + const ztNetTotal = ztNetSeats + ztNetRouters; + + const voipSeatRate = VOIP_RATES[voipTier] || 28; + const voipSeatsAmt = voipSeats * voipSeatRate; + const voipPhoneAmt = addVoipPhone ? voipSeats * VOIP_PHONE_RATE : 0; + const voipFaxAmt = addVoipFax ? VOIP_FAX_RATE : 0; + const voipTotal = voipSeatsAmt + voipPhoneAmt + voipFaxAmt; + + const MRR = userTotal + endpointTotal + adminFeeNet + ztNetTotal + voipTotal; + const annual = MRR * 12; + const perUserAllin = users > 0 ? MRR / users : 0; + + // Contract term discount + const discountPct = CONTRACT_DISCOUNT[contractTerm] || 0; + const discountAmt = Math.round(MRR * discountPct); + const effectiveMrr = MRR - discountAmt; + const effectiveAnnual = effectiveMrr * 12; + + // HST (Ontario 13%) + const hstAmt = hstEnabled ? Math.round(effectiveMrr * HST_RATE) : 0; + const mrrWithHst = effectiveMrr + hstAmt; + + // Per-user cost breakdown: user-driven services vs site overhead spread across users + const perUserServices = users > 0 ? Math.round(userTotal / users) : 0; + const perUserSiteOvhd = users > 0 ? Math.round((effectiveMrr - userTotal) / users) : 0; + + return { + users, endpoints, servers, ztSeats, ztRouters, voipSeats, voipTier, addVoipPhone, addVoipFax, + byol, addPWM, addINKY, addExtHours, addZT, addUSB, addBMB, ztActive, clientName, + contractTerm, hstEnabled, oneTimeFee, + baseUserRate, totalUserRate, + userBase, userPWM, userINKY, userExt, userZT, userTotal, + endpointBase, serverBase, endpointUSB, endpointBMB, endpointTotal, + ztNetSeats, ztNetRouters, ztNetTotal, + voipSeatRate, voipSeatsAmt, voipPhoneAmt, voipFaxAmt, voipTotal, + baseSubtotal, siteAdminBase, admin1PWM, adminFeeNet, + MRR, annual, perUserAllin, + discountPct, discountAmt, effectiveMrr, effectiveAnnual, + hstAmt, mrrWithHst, + perUserServices, perUserSiteOvhd + }; +} + +// --- MAIN UPDATE --- +// ── update() ───────────────────────────────────────────────────── +// Master DOM update function. Called on every input change. +// Sequence: calcQuote → update all displays → renderNudge → +// updateSavings → updateVsComparison → updateSectionSummaries +// Also wrapped by the mobile IIFE below to sync _m elements. +// Do not call renderNudge() directly from other functions; +// always go through update() to keep _m panel in sync. +function update() { + const q = calcQuote(); + + // ── Onboarding fee: auto = 50% MRR unless manually set or waived ── + // 12-month and 24-month contracts auto-waive the onboarding fee. + const waivedEl = document.getElementById('onboardingWaived'); + const feeEl = document.getElementById('oneTimeFee'); + if (waivedEl && (q.contractTerm === '12mo' || q.contractTerm === '24mo')) { + waivedEl.checked = true; + waivedEl.disabled = true; + waivedEl.dataset.autoWaived = '1'; + if (feeEl) delete feeEl.dataset.manual; + } else if (waivedEl) { + waivedEl.disabled = false; + if (waivedEl.dataset.autoWaived) { + waivedEl.checked = false; + delete waivedEl.dataset.autoWaived; + } + } + const waived = waivedEl?.checked || false; + let oneTimeFee; + if (waived) { + oneTimeFee = 0; + if (feeEl) { feeEl.value = ''; feeEl.disabled = true; } + } else { + if (feeEl) feeEl.disabled = false; + if (feeEl && !feeEl.dataset.manual) { + oneTimeFee = Math.round(q.MRR / 2); + feeEl.value = oneTimeFee > 0 ? oneTimeFee : ''; + } else { + oneTimeFee = parseFloat(feeEl?.value) || 0; + } + } + q.oneTimeFee = oneTimeFee; + + const { users, endpoints, servers, voipSeats, voipTier, + byol, addPWM, addINKY, addExtHours, addZT, addUSB, addBMB, ztActive, clientName, + contractTerm, hstEnabled, + baseUserRate, + userPWM, userINKY, userExt, userZT, userTotal, + serverBase, endpointUSB, endpointBMB, endpointTotal, + ztNetTotal, + voipTotal, + baseSubtotal, siteAdminBase, admin1PWM, adminFeeNet, + MRR, + discountPct, discountAmt, effectiveMrr, effectiveAnnual, + hstAmt, mrrWithHst, + perUserServices, perUserSiteOvhd } = q; + + // Client name display + ['clientNameDisplay', 'clientNameDisplay_m'].forEach(id => { + const el = document.getElementById(id); + if (!el) return; + el.textContent = clientName || 'Client Name'; + el.classList.toggle('placeholder', !clientName); + }); + + // Admin Fee + getEl('adminFeeDisplay').textContent = fmt(adminFeeNet) + '/mo'; + const fillPct = Math.min(100, Math.max(0, (baseSubtotal / ADMIN_FEE_MINIMUM) * 100)); + getEl('floorBar').style.width = fillPct + '%'; + getEl('floorProgress').textContent = fmt(baseSubtotal) + ' / ' + fmt(ADMIN_FEE_MINIMUM); + const atFloor = baseSubtotal >= ADMIN_FEE_MINIMUM; + getEl('floorBar').style.background = atFloor ? 'var(--green)' : 'var(--accent)'; + getEl('floorNote').textContent = atFloor + ? '✓ Minimum threshold reached — site fee at floor ($150/mo)' + : `$${Math.max(0, ADMIN_FEE_MINIMUM - baseSubtotal).toLocaleString()} more in services reduces admin fee further`; + + getEl('fb-base').textContent = fmt(siteAdminBase); + getEl('fb-zt-row').classList.toggle('hidden', !ztActive); + getEl('fb-pwm-row').classList.toggle('hidden', !addPWM); + getEl('fb-pwm').textContent = '+' + fmt(admin1PWM); + getEl('fb-total').textContent = fmt(adminFeeNet); + + // Sidebar lines + const show = (id, val) => { + const el = getEl(id); if (!el) return; + el.classList.toggle('hidden', !val); + }; + show('sl-users', users > 0); + if (users > 0) { + getEl('sl-users-val').textContent = fmt(userTotal); + const sub = getEl('sl-users-sub'); + sub.style.display = ''; + const subParts = [`${users} × ${fmt(baseUserRate)}/user (${byol ? 'BYOL' : 'M365 Incl.'})`]; + if (addExtHours) subParts.push(`+ ${fmt(userExt)}/mo ext. hrs`); + if (addPWM) subParts.push(`+ ${fmt(userPWM)}/mo 1Password`); + if (addINKY) subParts.push(`+ ${fmt(userINKY)}/mo INKY`); + if (addZT) subParts.push(`+ ${fmt(userZT)}/mo Zero Trust`); + sub.innerHTML = subParts.join('
'); + } + show('sl-endpoints', endpoints > 0); + if (endpoints > 0) { + // endpointTotal includes serverBase — display endpoints-only so servers line doesn't double-count + const epOnly = endpointTotal - serverBase; + getEl('sl-endpoints-val').textContent = fmt(epOnly); + const sub = getEl('sl-endpoints-sub'); + sub.style.display = ''; + const epParts = [`${endpoints} × $35/endpoint`]; + if (addBMB) epParts.push(`+ ${fmt(endpointBMB)}/mo Bare Metal Backup`); + if (addUSB) epParts.push(`+ ${fmt(endpointUSB)}/mo USB Blocking`); + sub.innerHTML = epParts.join('
'); + } + show('sl-servers', servers > 0); + if (servers > 0) getEl('sl-servers-val').textContent = fmt(serverBase); + show('sl-zt', ztNetTotal > 0); + if (ztNetTotal > 0) getEl('sl-zt-val').textContent = fmt(ztNetTotal); + show('sl-voip', voipTotal > 0); + if (voipTotal > 0) getEl('sl-voip-val').textContent = fmt(voipTotal); + getEl('sl-admin-val').textContent = fmt(adminFeeNet); + + // MRR + totals — show effective MRR (after term discount) as the headline number + getEl('mrrDisplay').textContent = fmt(effectiveMrr); + getEl('annualDisplay').textContent = fmt(effectiveAnnual); + getEl('perUserRow').style.display = users > 0 ? '' : 'none'; + if (users > 0) getEl('perUserDisplay').textContent = fmt(effectiveMrr / users) + '/user'; + + // Discount row (only shown when a term discount is active) + const discountRow = getEl('sl-discount-row'); + if (discountRow) { + discountRow.classList.toggle('hidden', discountPct === 0); + if (discountPct > 0) { + const termLabel = contractTerm === '12mo' ? '12-Month' : '24-Month'; + const discEl = getEl('sl-discount-val'); + if (discEl) discEl.textContent = `−${fmt(discountAmt)}/mo (${termLabel}, ${Math.round(discountPct*100)}% off)`; + } + } + + // Base MRR (pre-discount) row — shown only when discount is active + const baseMrrRow = getEl('sl-base-mrr-row'); + if (baseMrrRow) baseMrrRow.classList.toggle('hidden', discountPct === 0); + const baseMrrEl = getEl('sl-base-mrr-val'); + if (baseMrrEl && discountPct > 0) baseMrrEl.textContent = fmt(MRR) + '/mo'; + + // HST row + Total row + getEl('sl-hst-row')?.classList.toggle('hidden', !hstEnabled); + getEl('sl-hst-total-row')?.classList.toggle('hidden', !hstEnabled); + if (hstEnabled) { + const hstEl = getEl('sl-hst-val'); + if (hstEl) hstEl.textContent = `+${fmt(hstAmt)}`; + const totalEl = getEl('sl-hst-total-val'); + if (totalEl) totalEl.textContent = fmt(mrrWithHst) + '/mo'; + } + // Sync mobile HST toggle state + const hstToggleMobile = document.getElementById('hstToggle_m'); + if (hstToggleMobile) hstToggleMobile.checked = hstEnabled; + + // Onboarding fee row — show fee, or "WAIVED" savings if waived + const _waived = document.getElementById('onboardingWaived')?.checked || false; + const _wouldBe = Math.round(q.MRR / 2); + ['sl-otf-row', 'sl-otf-row_m'].forEach(rowId => { + const row = getEl(rowId); + if (!row) return; + const isMobile = rowId.endsWith('_m'); + const valEl = getEl(isMobile ? 'sl-otf-val_m' : 'sl-otf-val'); + if (_waived && _wouldBe > 0) { + row.classList.remove('hidden'); + row.classList.add('sl-otf-waived'); + if (valEl) valEl.textContent = fmt(_wouldBe) + ' WAIVED'; + } else if (oneTimeFee > 0) { + row.classList.remove('hidden', 'sl-otf-waived'); + if (valEl) valEl.textContent = fmt(oneTimeFee) + ' (one-time)'; + } else { + row.classList.add('hidden'); + row.classList.remove('sl-otf-waived'); + } + }); + + // Per-user breakdown sub-line + const puBreakdown = getEl('perUserBreakdown'); + if (puBreakdown) { + puBreakdown.classList.toggle('hidden', users === 0); + if (users > 0) { + puBreakdown.textContent = `${fmt(perUserServices)} user services + ${fmt(perUserSiteOvhd)} site overhead`; + } + } + + // Sidebar notes + getEl('adminPct').textContent = MRR > 0 ? Math.round(adminFeeNet / MRR * 100) : '—'; + getEl('sideNote-m365').classList.toggle('hidden', byol); + getEl('sideNote-byol').classList.toggle('hidden', !byol); + if (!byol && users > 0) getEl('m365SaveAmt').textContent = fmt(users * 15); + + // BYOL callouts + getEl('byolCalloutGreen').classList.toggle('hidden', byol); + getEl('byolCalloutRed').classList.toggle('hidden', !byol); + if (byol) getEl('byolRedSavings').textContent = fmt(users * 15); + + // VoIP tier active state + ['basic','standard','premium'].forEach(t => { + const seg = getEl('seg-' + t); + if (seg) seg.classList.toggle('active', t === voipTier); + }); + + // Contract term active state + ['m2m','12mo','24mo'].forEach(t => { + const seg = getEl('seg-term-' + t); + if (seg) seg.classList.toggle('active', t === contractTerm); + }); + + // Nudges — dynamic dollar values, context-sensitive conditions + const nudges = []; + if (!addZT && users > 0) nudges.push({ + text: `Zero Trust Deny By Default adds enterprise-grade access control — ${fmt(users * 55)}/mo for all ${users} user${users !== 1 ? 's' : ''}. Recommended for any client with remote staff or sensitive data.`, + color: 'amber' + }); + if (!addPWM && users > 0) nudges.push({ + text: `1Password Management — one vault for every credential, enforce strong passwords, revoke access instantly when staff leave. Only ${fmt(users * 9)}/mo for ${users} user${users !== 1 ? 's' : ''}.`, + color: 'green' + }); + if (byol && users > 0) nudges.push({ + text: `BYOL selected — switching to M365 Included ($130/user) bundles the license and saves the client up to ${fmt(users * 15)}/mo vs retail Microsoft 365 pricing.`, + color: 'green' + }); + if (endpoints > 0 && !addBMB) nudges.push({ + text: `Bare Metal Backup protects against ransomware with image-level restore — ${fmt(endpoints * 25)}/mo covers all ${endpoints} endpoint${endpoints !== 1 ? 's' : ''}. Fast, bare-metal recovery if the worst happens.`, + color: 'amber' + }); + if (voipSeats > 0 && voipTier === 'basic') nudges.push({ + text: `Standard tier adds HD video, call analytics, and advanced call routing — only ${fmt(voipSeats * (VOIP_RATES.standard - VOIP_RATES.basic))}/mo more for ${voipSeats} seat${voipSeats !== 1 ? 's' : ''}. Most clients upgrade within 6 months.`, + color: 'green' + }); + if (servers > 0 && !addBMB) nudges.push({ + text: `Servers are in scope but Bare Metal Backup is not selected — adding BMB on ${endpoints} endpoint${endpoints !== 1 ? 's' : ''} would also cover server backup in a complete DR story.`, + color: 'amber' + }); + if (users > 0 && endpoints > 0 && endpoints > users * 1.5) nudges.push({ + text: `${endpoints} endpoints vs ${users} users — that's a high endpoint-to-user ratio. Consider whether unmanaged devices should be attached to user seats to ensure full coverage.`, + color: 'amber' + }); + window._nudges = nudges; + if (!window._nudgeIndex || window._nudgeIndex >= nudges.length) window._nudgeIndex = 0; + + renderNudge(); + updateSavings(q); + updateVsComparison(q); + updateSectionSummaries(q); + debouncedSave(); +} + +// ── toggleSection(id) ──────────────────────────────────────────── +// Collapses/expands a numbered section card. +// Adds/removes .sec-open on the section element. +// .sec-open → chevron rotates 180deg (CSS), body shown (JS display). +// Calls updateSectionSummaries() to show/hide summary badges. +function toggleSection(id) { + const section = document.getElementById(id); + const body = document.getElementById(id + '-body'); + if (!section || !body) return; + const isOpen = section.classList.toggle('sec-open'); + body.style.display = isOpen ? '' : 'none'; + updateSectionSummaries(); +} + +// ── updateSectionSummaries(q) ──────────────────────────────────── +// Shows/hides summary badges on collapsed sections. +// setSummary(id, text) → display:inline-block when section collapsed +// and text is non-empty; display:none otherwise. +// Called by update() and toggleSection(). +function updateSectionSummaries(q) { + q = q || calcQuote(); + const collapsed = id => !document.getElementById(id)?.classList.contains('sec-open'); + const setSummary = (id, text) => { + const el = document.getElementById(id); + if (!el) return; + const secId = id.replace('-summary','').replace('sec0','sec-0'); + const show = collapsed(secId) && !!text; + el.textContent = text || ''; + el.style.display = show ? 'inline-block' : 'none'; + }; + + setSummary('sec01-summary', fmt(q.adminFeeNet) + '/mo'); + setSummary('sec02-summary', q.users > 0 ? `${q.users} user${q.users !== 1 ? 's' : ''} · ${fmt(q.userTotal)}/mo` : ''); + setSummary('sec03-summary', q.endpoints > 0 ? `${q.endpoints} endpoint${q.endpoints !== 1 ? 's' : ''} · ${fmt(q.endpointTotal - q.serverBase)}/mo` : ''); + setSummary('sec04-summary', q.servers > 0 ? `${q.servers} server${q.servers !== 1 ? 's' : ''} · ${fmt(q.serverBase)}/mo` : ''); + const ztItems = []; + if (q.ztSeats > 0) ztItems.push(`${q.ztSeats} seat${q.ztSeats !== 1 ? 's' : ''}`); + if (q.ztRouters > 0) ztItems.push(`${q.ztRouters} device${q.ztRouters !== 1 ? 's' : ''}`); + setSummary('sec05-summary', q.ztNetTotal > 0 ? ztItems.join(' · ') + ` · ${fmt(q.ztNetTotal)}/mo` : ''); + setSummary('sec06-summary', q.voipSeats > 0 ? `${q.voipSeats} seat${q.voipSeats !== 1 ? 's' : ''} · ${fmt(q.voipTotal)}/mo` : ''); +} + +// ── toggleCollapsible(id) ───────────────────────────────────────── +// Collapses/expands inner content panels (What's Included, Add-Ons). +// Separate from section-level toggle. Toggles .open on .collapsible-body. +// Also toggles preview pills (shown when collapsed, hidden when open). +function toggleCollapsible(id) { + const body = document.getElementById(id); + const icon = document.getElementById(id + '-icon'); + const preview = document.getElementById(id + '-preview'); + if (!body) return; + const open = body.classList.toggle('open'); + if (icon) icon.classList.toggle('open', open); + if (preview) preview.style.display = open ? 'none' : 'flex'; +} + +// ── toggleAddon(checkId, rowId) ───────────────────────────────── +// Flips the hidden checkbox + toggles .selected on the visible row. +// Called via onclick on the