// ── PRICING VARIABLES ─────────────────────────────────────────── // All rates in CAD. Loaded from package-prices.csv at startup. // Falls back to built-in defaults if CSV is unavailable. // 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 // ───────────────────────────────────────────────────────────────── let RATE_M365 = 130; let RATE_BYOL = 110; let ADDON_EXT_HOURS = 25; let ADDON_1PASSWORD = 9; let ADDON_INKY = 5; let ADDON_ZERO_TRUST_USER= 55; let RATE_ENDPOINT = 35; let RATE_SERVER = 120; let ADDON_USB_BLOCKING = 4; let ADDON_BARE_METAL_BACKUP = 25; let ZT_SEAT_RATE = 25; let ZT_ROUTER_RATE = 100; let ADMIN_FEE_FLOOR = 150; let ADMIN_FEE_MINIMUM = 650; let ADMIN_FEE_ZT = 250; let ADMIN_1PWM_PCT = 0.10; let VOIP_RATE_BASIC = 28; let VOIP_RATE_STANDARD = 35; let VOIP_RATE_PREMIUM = 45; let VOIP_PHONE_RATE = 15; let VOIP_FAX_RATE = 10; let TOOL_COST_PER_USER = 42; let TOOL_COST_PER_ENDPOINT = 23; let TOOL_COST_MIN = 650; let IT_SALARY_1 = 85000; let 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 let DISCOUNT_M2M = 0; let DISCOUNT_12MO = 0.03; let DISCOUNT_24MO = 0.05; let HST_RATE = 0.13; // Ontario HST 13% // ── loadPricing() ──────────────────────────────────────────────── // Fetches package-prices.csv and overrides the pricing variables above. // Silently falls back to built-in defaults if CSV is missing or malformed. async function loadPricing() { try { const res = await fetch('package-prices.csv'); if (!res.ok) return; const text = await res.text(); const lines = text.split('\n').slice(1); // skip header row lines.forEach(line => { const parts = line.split(','); if (parts.length < 3) return; const key = parts[1].trim(); const val = parseFloat(parts[2].trim()); if (isNaN(val)) return; switch (key) { case 'RATE_M365': RATE_M365 = val; break; case 'RATE_BYOL': RATE_BYOL = val; break; case 'ADDON_EXT_HOURS': ADDON_EXT_HOURS = val; break; case 'ADDON_1PASSWORD': ADDON_1PASSWORD = val; break; case 'ADDON_INKY': ADDON_INKY = val; break; case 'ADDON_ZERO_TRUST_USER': ADDON_ZERO_TRUST_USER = val; break; case 'RATE_ENDPOINT': RATE_ENDPOINT = val; break; case 'RATE_SERVER': RATE_SERVER = val; break; case 'ADDON_USB_BLOCKING': ADDON_USB_BLOCKING = val; break; case 'ADDON_BARE_METAL_BACKUP': ADDON_BARE_METAL_BACKUP = val; break; case 'ZT_SEAT_RATE': ZT_SEAT_RATE = val; break; case 'ZT_ROUTER_RATE': ZT_ROUTER_RATE = val; break; case 'ADMIN_FEE_FLOOR': ADMIN_FEE_FLOOR = val; break; case 'ADMIN_FEE_MINIMUM': ADMIN_FEE_MINIMUM = val; break; case 'ADMIN_FEE_ZT': ADMIN_FEE_ZT = val; break; case 'ADMIN_1PWM_PCT': ADMIN_1PWM_PCT = val; break; case 'VOIP_RATE_BASIC': VOIP_RATE_BASIC = val; break; case 'VOIP_RATE_STANDARD': VOIP_RATE_STANDARD = val; break; case 'VOIP_RATE_PREMIUM': VOIP_RATE_PREMIUM = val; break; case 'VOIP_PHONE_RATE': VOIP_PHONE_RATE = val; break; case 'VOIP_FAX_RATE': VOIP_FAX_RATE = val; break; case 'TOOL_COST_PER_USER': TOOL_COST_PER_USER = val; break; case 'TOOL_COST_PER_ENDPOINT':TOOL_COST_PER_ENDPOINT= val; break; case 'TOOL_COST_MIN': TOOL_COST_MIN = val; break; case 'IT_SALARY_1': IT_SALARY_1 = val; break; case 'IT_SALARY_5': IT_SALARY_5 = val; break; case 'DISCOUNT_M2M': DISCOUNT_M2M = val; break; case 'DISCOUNT_12MO': DISCOUNT_12MO = val; break; case 'DISCOUNT_24MO': DISCOUNT_24MO = val; break; case 'HST_RATE': HST_RATE = val; break; } }); } catch (e) { // CSV unavailable — built-in defaults remain active } } // --- 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 adminWaived = document.getElementById('adminWaived')?.checked || false; const VOIP_RATES = { basic: VOIP_RATE_BASIC, standard: VOIP_RATE_STANDARD, premium: VOIP_RATE_PREMIUM }; const CONTRACT_DISCOUNT = { 'm2m': DISCOUNT_M2M, '12mo': DISCOUNT_12MO, '24mo': DISCOUNT_24MO }; const baseUserRate = byol ? RATE_BYOL : RATE_M365; const addonRate = (addExtHours ? ADDON_EXT_HOURS : 0) + (addPWM ? ADDON_1PASSWORD : 0) + (addINKY ? ADDON_INKY : 0) + (addZT ? ADDON_ZERO_TRUST_USER : 0); const totalUserRate= baseUserRate + addonRate; const userBase = users * baseUserRate; const userExt = addExtHours ? users * ADDON_EXT_HOURS : 0; const userPWM = addPWM ? users * ADDON_1PASSWORD : 0; const userINKY = addINKY ? users * ADDON_INKY : 0; const userZT = addZT ? users * ADDON_ZERO_TRUST_USER : 0; const userTotal = userBase + userExt + userPWM + userINKY + userZT; const endpointBase = endpoints * RATE_ENDPOINT; const serverBase = servers * RATE_SERVER; const endpointUSB = addUSB ? endpoints * ADDON_USB_BLOCKING : 0; const endpointBMB = addBMB ? endpoints * ADDON_BARE_METAL_BACKUP : 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 * ADMIN_1PWM_PCT) : 0; const adminFeeNet = siteAdminBase + (ztActive ? ADMIN_FEE_ZT : 0) + admin1PWM; const adminFeeEffective = adminWaived ? 0 : adminFeeNet; const ztNetSeats = ztSeats * ZT_SEAT_RATE; const ztNetRouters = ztRouters * ZT_ROUTER_RATE; const ztNetTotal = ztNetSeats + ztNetRouters; const voipSeatRate = VOIP_RATES[voipTier] || VOIP_RATE_BASIC; const voipSeatsAmt = voipSeats * voipSeatRate; const voipPhoneAmt = addVoipPhone ? voipSeats * VOIP_PHONE_RATE : 0; const voipFaxAmt = addVoipFax ? VOIP_FAX_RATE : 0; const voipTotal = voipSeatsAmt + voipPhoneAmt + voipFaxAmt; const MRR = userTotal + endpointTotal + adminFeeEffective + 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, adminWaived, 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; feeEl.placeholder = 'Complimentary'; } } else { if (feeEl) { feeEl.disabled = false; feeEl.placeholder = 'auto'; } 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, adminWaived, 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 if (adminWaived) { getEl('adminFeeDisplay').innerHTML = `${fmt(adminFeeNet)}/mo WAIVED`; } else { 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); if (adminWaived) { getEl('fb-total').innerHTML = `${fmt(adminFeeNet)} WAIVED`; } else { getEl('fb-total').textContent = fmt(adminFeeNet); } // Savings callout below fee table const adminWaivedSavingsEl = document.getElementById('adminWaivedSavings'); if (adminWaivedSavingsEl) { adminWaivedSavingsEl.classList.toggle('hidden', !adminWaived || adminFeeNet === 0); const awAmt = document.getElementById('adminWaivedAmt'); if (awAmt) awAmt.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); const slAdminEl = getEl('sl-admin'); const slAdminValEl = getEl('sl-admin-val'); if (adminWaived) { slAdminEl?.classList.add('sl-admin-waived'); if (slAdminValEl) slAdminValEl.innerHTML = `${fmt(adminFeeNet)} WAIVED`; } else { slAdminEl?.classList.remove('sl-admin-waived'); if (slAdminValEl) slAdminValEl.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.innerHTML = '' + 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('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); }); // ── Contract savings display ───────────────────────────────── const savingsRow = document.getElementById('qsSavingsDisplay'); const savingsAmt = document.getElementById('qsSavingsAmt'); if (savingsRow && savingsAmt) { if (q.discountAmt > 0) { savingsAmt.textContent = fmt(q.discountAmt); savingsRow.classList.remove('hidden'); } else { savingsRow.classList.add('hidden'); } } // 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_RATE_STANDARD - VOIP_RATE_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 == null || 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(); updateToggleAllBtn(); } // ── toggleAllSections() / updateToggleAllBtn() ──────────────────── // Collapse all if any are open; expand all if all are closed. // Button label reflects current state. const _allSecIds = ['sec-01','sec-02','sec-03','sec-04','sec-05','sec-06']; function toggleAllSections() { const anyOpen = _allSecIds.some(id => document.getElementById(id)?.classList.contains('sec-open')); _allSecIds.forEach(id => { const section = document.getElementById(id); const body = document.getElementById(id + '-body'); if (!section || !body) return; if (anyOpen) { section.classList.remove('sec-open'); body.style.display = 'none'; } else { section.classList.add('sec-open'); body.style.display = ''; } }); updateSectionSummaries(); updateToggleAllBtn(); } function updateToggleAllBtn() { const anyOpen = _allSecIds.some(id => document.getElementById(id)?.classList.contains('sec-open')); const btn = document.getElementById('toggleAllBtn'); const collapseIcon = ''; const expandIcon = ''; if (btn) btn.innerHTML = anyOpen ? collapseIcon + 'Collapse All' : expandIcon + 'Expand All'; } // ── stepCount(inputId, delta, event) ───────────────────────────── // Increments or decrements a num-input by delta (±1) from the // collapsed section counter. Stops propagation so the click doesn't // bubble up and toggle the section open/closed. function stepCount(inputId, delta, event) { if (event) event.stopPropagation(); const el = document.getElementById(inputId); if (!el) return; const min = parseInt(el.min) >= 0 ? parseInt(el.min) : 0; el.value = Math.max(min, (parseInt(el.value) || 0) + delta); update(); } // ── 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', q.adminWaived ? 'WAIVED' : 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 element. // The label's onclick fires BEFORE the native checkbox change, // so we manually invert cb.checked here instead of reading it. function toggleAddon(checkId, rowId) { const cb = document.getElementById(checkId); const row = document.getElementById(rowId); cb.checked = !cb.checked; if (row) row.classList.toggle('selected', cb.checked); } // ── activateTier(tier) ─────────────────────────────────────────── // Sets the active VoIP tier pill (basic/standard/premium). // Adds .active class to matching .tier-seg, removes from others. // Called from onclick on tier labels (alongside update()). function activateTier(tier) { const el = document.getElementById('voip' + tier.charAt(0).toUpperCase() + tier.slice(1)); if (el) { el.checked = true; update(); } } // ── updateVsComparison(q) ──────────────────────────────────────── // Renders the "VS Hiring In-House" comparison table in the sidebar. // Shown only when users > 0 OR endpoints > 0. // Compares annualised SVS MRR vs 1-person ($85K+tools) and // 5-person team ($420K+tools). Rows turn amber when SVS costs more. // Updates both desktop (#vsComparison) and mobile (_m) via syncClass. function updateVsComparison(q) { const vsEl = document.getElementById('vsComparison'); if (!vsEl) return; q = q || calcQuote(); const { users, endpoints, effectiveAnnual } = q; if (users < 1 && endpoints < 1) { vsEl.classList.add('hidden'); return; } vsEl.classList.remove('hidden'); const toolsMonthly = Math.max(TOOL_COST_MIN, (users * TOOL_COST_PER_USER) + (endpoints * TOOL_COST_PER_ENDPOINT)); const toolsAnnual = toolsMonthly * 12; const cost1 = IT_SALARY_1 + toolsAnnual; const cost5 = IT_SALARY_5 + toolsAnnual; const save1 = cost1 - effectiveAnnual; const save5 = cost5 - effectiveAnnual; const fmtK = n => n >= 0 ? '$' + Math.round(n/1000) + 'K/yr' : '–$' + Math.round(Math.abs(n)/1000) + 'K/yr'; getEl('vs-svs-annual').textContent = '$' + Math.round(effectiveAnnual/1000) + 'K/yr'; getEl('vs-1man-cost').textContent = '$' + Math.round(cost1/1000) + 'K/yr'; getEl('vs-5man-cost').textContent = '$' + Math.round(cost5/1000) + 'K/yr'; const applyVsRow = (rowId, saveId, labelId, saving) => { const row = getEl(rowId); const val = getEl(saveId); const lbl = getEl(labelId); if (!row || !val || !lbl) return; val.textContent = fmtK(saving); row.className = row.className.replace(/\bvs-save-green\b|\bvs-save-amber\b/g, '').trim(); val.className = val.className.replace(/\bvs-val-green\b|\bvs-val-amber\b/g, '').trim(); lbl.className = lbl.className.replace(/\bvs-val-green\b|\bvs-val-amber\b/g, '').trim(); if (saving > 0) { row.classList.add('vs-save-green'); val.classList.add('vs-val-green'); lbl.classList.add('vs-val-green'); lbl.textContent = 'YOU SAVE'; } else { row.classList.add('vs-save-amber'); val.classList.add('vs-val-amber'); lbl.classList.add('vs-val-amber'); lbl.textContent = 'Costs more'; } }; applyVsRow('vs-1man-save-row', 'vs-1man-save', 'vs-1man-save-lbl', save1); applyVsRow('vs-5man-save-row', 'vs-5man-save', 'vs-5man-save-lbl', save5); const toolsLabel = toolsMonthly <= TOOL_COST_MIN ? `min $${TOOL_COST_MIN}/mo` : `~$${toolsMonthly}/mo`; getEl('vs-footnote').textContent = `Based on ~$85K Ottawa IT salary (2024) + ${toolsLabel} tool licensing (M365, EDR, RMM, backup, SAT & more). No benefits, hiring, or turnover costs factored.`; } // ── renderNudge() ───────────────────────────────────────────────── // Renders the active nudge insight in BOTH sidebar banners. // Reads window._nudges[] and window._nudgeIndex. // applyNudge('') → desktop #nudgeBanner // applyNudge('_m') → mobile panel #nudgeBanner_m // Always call renderNudge() not applyNudge() directly. function renderNudge() { const nudges = window._nudges || []; const idx = window._nudgeIndex || 0; function applyNudge(suffix) { const s = suffix || ''; const banner = document.getElementById('nudgeBanner' + s); if (!banner) return; if (!nudges.length) { banner.classList.add('hidden'); return; } const n = nudges[idx]; banner.classList.remove('hidden', 'amber', 'green'); banner.classList.add(n.color); const textEl = document.getElementById('nudgeText' + s); const cntEl = document.getElementById('nudgeCounter' + s); if (textEl) textEl.textContent = n.text; if (cntEl) cntEl.textContent = nudges.length > 1 ? `${idx + 1}/${nudges.length}` : ''; const btns = banner.querySelectorAll('button'); btns.forEach(b => b.style.display = nudges.length >= 1 ? 'flex' : 'none'); } applyNudge(''); applyNudge('_m'); } // ── cycleNudge(dir) ────────────────────────────────────────────── // Manual nudge navigation. dir: +1 (next) or -1 (prev). // Does NOT reset the 30s auto-rotation timer (intentional). function cycleNudge(dir) { const nudges = window._nudges || []; if (!nudges.length) return; window._nudgeIndex = ((window._nudgeIndex || 0) + dir + nudges.length) % nudges.length; renderNudge(); } // ── startNudgeRotation() ───────────────────────────────────────── // Starts 30-second auto-advance timer for nudge insights. // Clears existing timer first to prevent duplicates. // Called once on page load. Timer advances index directly // (does not call cycleNudge) so manual nav doesn't reset it. function startNudgeRotation() { if (window._nudgeTimer) clearInterval(window._nudgeTimer); window._nudgeTimer = setInterval(() => { const nudges = window._nudges || []; if (nudges.length > 1) { window._nudgeIndex = ((window._nudgeIndex || 0) + 1) % nudges.length; renderNudge(); } }, 30000); } startNudgeRotation(); // ── updateSavings(q) ───────────────────────────────────────────── // Renders VoIP phone bill savings comparator (Section VI). // Reads #currentPhoneBill input. Shows green savings or amber // warning depending on whether VoIP total is lower or higher. // q is optional — falls back to calcQuote() if called bare // (e.g. from oninput on #currentPhoneBill directly). function updateSavings(q) { const billEl = document.getElementById('currentPhoneBill'); const comparator = document.getElementById('savingsComparator'); const prompt = document.getElementById('savingsPrompt'); if (!billEl || !comparator) return; const bill = parseFloat(billEl.value) || 0; q = q || calcQuote(); const { voipTotal, voipSeats } = q; if (voipSeats < 1) { comparator.classList.add('hidden'); if(prompt) prompt.style.display='none'; return; } if (prompt) prompt.style.display = bill === 0 ? '' : 'none'; if (bill === 0) { comparator.classList.add('hidden'); return; } comparator.classList.remove('hidden'); const saving = bill - voipTotal; if (saving > 0) { comparator.textContent = `✓ Switching to SVS VoIP saves ~${fmt(saving)}/mo (${fmt(saving*12)}/yr) vs your current bill of ${fmt(bill)}/mo`; comparator.style.color = ''; } else { comparator.textContent = `Your current bill (${fmt(bill)}/mo) is lower than this VoIP quote (${fmt(voipTotal)}/mo) — consider reviewing the tier or seat count.`; comparator.style.color = 'var(--amber)'; } } // ── HELPERS ────────────────────────────────────────────────────── // fmt(n) — formats number as $1,234 string // getEl(id) — shorthand for document.getElementById(id) // stepInput — increment/decrement a num-input by delta, respects min function fmt(n) { return '$' + Math.round(n).toLocaleString('en-US'); } function getEl(id) { return document.getElementById(id); } function stepInput(id, delta) { const el = document.getElementById(id); if (!el) return; const min = parseInt(el.min) ?? 0; el.value = Math.max(min, (parseInt(el.value) || 0) + delta); update(); } // --- AUTO-SAVE / RESTORE --- const SAVE_KEY = 'svs-msp-quote-v1'; function saveState() { try { const state = { clientName: document.getElementById('clientName')?.value || '', users: parseInt(document.getElementById('userCount')?.value) || 0, endpoints: parseInt(document.getElementById('endpointCount')?.value) || 0, servers: parseInt(document.getElementById('serverCount')?.value) || 0, byol: document.getElementById('rateBYOL')?.checked || false, addExtHours: document.getElementById('addExtHours')?.checked || false, addPWM: document.getElementById('addPWM')?.checked || false, addINKY: document.getElementById('addINKY')?.checked || false, addZT: document.getElementById('addZT')?.checked || false, addUSB: document.getElementById('addUSB')?.checked || false, addBMB: document.getElementById('addBMB')?.checked || false, ztSeats: parseInt(document.getElementById('ztNetSeats')?.value) || 0, ztRouters: parseInt(document.getElementById('ztNetRouters')?.value) || 0, voipTier: (document.querySelector('input[name="voipTier"]:checked') || {}).value || 'basic', voipSeats: parseInt(document.getElementById('voipSeats')?.value) || 0, addVoipPhone: document.getElementById('addVoipPhone')?.checked || false, addVoipFax: document.getElementById('addVoipFax')?.checked || false, phoneBill: parseFloat(document.getElementById('currentPhoneBill')?.value) || 0, contractTerm: (document.querySelector('input[name="contractTerm"]:checked') || {}).value || 'm2m', hstEnabled: document.getElementById('hstToggle')?.checked || false, oneTimeFee: parseFloat(document.getElementById('oneTimeFee')?.value) || 0, onboardingWaived: document.getElementById('onboardingWaived')?.checked || false, onboardingManual: document.getElementById('oneTimeFee')?.dataset.manual === '1', }; localStorage.setItem(SAVE_KEY, JSON.stringify(state)); } catch(e) {} } let _saveTimer; function debouncedSave() { clearTimeout(_saveTimer); _saveTimer = setTimeout(saveState, 400); } // ── restoreState() ─────────────────────────────────────────────── // Restores form state from localStorage on page load. // SAVE_KEY = 'svs-msp-quote-v1'. function restoreState() { try { const raw = localStorage.getItem(SAVE_KEY); if (!raw) return false; const s = JSON.parse(raw); const set = (id, val) => { const el = document.getElementById(id); if (el) el.value = val; }; const check = (id, val) => { const el = document.getElementById(id); if (el) el.checked = !!val; }; set('clientName', s.clientName); set('userCount', s.users); set('endpointCount', s.endpoints); set('serverCount', s.servers); check('rateBYOL', s.byol); check('rateM365', !s.byol); check('addExtHours', s.addExtHours); check('addPWM', s.addPWM); check('addINKY', s.addINKY); check('addZT', s.addZT); check('addUSB', s.addUSB); check('addBMB', s.addBMB); set('ztNetSeats', s.ztSeats); set('ztNetRouters', s.ztRouters); const tierEl = document.querySelector(`input[name="voipTier"][value="${s.voipTier}"]`); if (tierEl) tierEl.checked = true; set('voipSeats', s.voipSeats); check('addVoipPhone',s.addVoipPhone); check('addVoipFax', s.addVoipFax); set('currentPhoneBill', s.phoneBill); const termEl = document.querySelector(`input[name="contractTerm"][value="${s.contractTerm || 'm2m'}"]`); if (termEl) termEl.checked = true; check('hstToggle', s.hstEnabled); check('onboardingWaived', s.onboardingWaived); if (s.onboardingManual && !s.onboardingWaived) { set('oneTimeFee', s.oneTimeFee || 0); const feeEl = document.getElementById('oneTimeFee'); if (feeEl) feeEl.dataset.manual = '1'; } // Restore addon row selected states ['addExtHours','addPWM','addINKY','addZT','addBMB','addUSB','addVoipPhone','addVoipFax'].forEach(id => { const cb = document.getElementById(id); if (cb?.checked) { const rowMap = { addExtHours:'row-ext', addPWM:'row-pwm', addINKY:'row-inky', addZT:'row-zt', addBMB:'row-bmb', addUSB:'row-usb', addVoipPhone:'row-vphone', addVoipFax:'row-vfax' }; const row = document.getElementById(rowMap[id]); if (row) row.classList.add('selected'); } }); const cn = document.getElementById('clientName'); const cd = document.getElementById('clientNameDisplay'); if (cn && cd) cd.textContent = cn.value || 'Client Name'; return true; } catch(e) { return false; } } // ── printInvoice() ──────────────────────────────────────────────── // Generates a clean invoice-style HTML document in a new window // and triggers the browser print dialog (Save as PDF works perfectly). function printInvoice() { const q = calcQuote(); const waived = document.getElementById('onboardingWaived')?.checked || false; const feeEl = document.getElementById('oneTimeFee'); const onboardingFee = waived ? 0 : (parseFloat(feeEl?.value) || Math.round(q.MRR / 2)); const waivedAmt = Math.round(q.MRR / 2); const quoteRef = document.getElementById('quoteRef')?.textContent || '—'; const quoteDate = document.getElementById('headerDate')?.textContent || '—'; const client = q.clientName || 'Client'; const termLabel = q.contractTerm === '12mo' ? '12-Month Contract — 3% off MRR' : q.contractTerm === '24mo' ? '24-Month Contract — 5% off MRR' : 'Month-to-Month'; // ── Build line items ─────────────────────────────────────────── const rows = []; const row = (label, detail, amt, sub) => rows.push({label, detail, amt, sub: !!sub}); if (q.users > 0) { const pkg = q.byol ? 'BYOL — Bring Your Own License' : 'M365 Included (Identity, Email & Protection)'; row(`User Package — ${pkg}`, `${q.users} user${q.users!==1?'s':''} × ${fmt(q.baseUserRate)}/mo`, fmt(q.userBase)); if (q.userExt > 0) row('↳ Extended Hours (+$25/user)', '', fmt(q.userExt), true); if (q.userPWM > 0) row('↳ 1Password Business (+$9/user)', '', fmt(q.userPWM), true); if (q.userINKY > 0) row('↳ Inky Email Security (+$5/user)', '', fmt(q.userINKY), true); if (q.userZT > 0) row('↳ Zero Trust User (+$55/user)', '', fmt(q.userZT), true); } if (q.endpoints > 0) { row('Endpoint Management', `${q.endpoints} endpoint${q.endpoints!==1?'s':''} × $35/mo`, fmt(q.endpointBase)); if (q.endpointUSB > 0) row('↳ USB Blocking (+$4/endpoint)', '', fmt(q.endpointUSB), true); if (q.endpointBMB > 0) row('↳ Bare Metal Backup (+$25/endpoint)', '', fmt(q.endpointBMB), true); } if (q.servers > 0) { row('Server Management', `${q.servers} server${q.servers!==1?'s':''} × $120/mo`, fmt(q.serverBase)); } if (q.ztNetTotal > 0) { row('Zero Trust Networking — HaaS', '', fmt(q.ztNetTotal)); if (q.ztNetSeats > 0) row(`↳ ZT Seats (${q.ztSeats} × $25/mo)`, '', fmt(q.ztNetSeats), true); if (q.ztNetRouters > 0) row(`↳ HaaS Routers (${q.ztRouters} × $100/mo)`, '', fmt(q.ztNetRouters), true); } if (q.voipTotal > 0) { const tier = {basic:'Basic',standard:'Standard',premium:'Premium'}[q.voipTier] || 'Basic'; row(`VoIP / UCaaS — ${tier}`, `${q.voipSeats} seat${q.voipSeats!==1?'s':''} × $${q.voipSeatRate}/mo`, fmt(q.voipSeatsAmt)); if (q.voipPhoneAmt > 0) row('↳ Desk Phone HaaS (+$15/seat)', '', fmt(q.voipPhoneAmt), true); if (q.voipFaxAmt > 0) row('↳ Virtual Fax (+$10/mo)', '', fmt(q.voipFaxAmt), true); } row('Site Admin Fee', 'Tenant, network, documentation & vendor management', fmt(q.adminFeeNet)); const itemsHTML = rows.map(r => ` ${r.label} ${r.detail} ${r.amt}/mo `).join(''); // ── Build totals ─────────────────────────────────────────────── let totals = ''; if (q.discountPct > 0) { totals += `Base MRR${fmt(q.MRR)}/mo`; totals += `Term Discount (${Math.round(q.discountPct*100)}% off)−${fmt(q.discountAmt)}/mo`; } totals += `Monthly Recurring (MRR)${fmt(q.effectiveMrr)}/mo`; if (q.hstEnabled) { totals += `Ontario HST (13%)+${fmt(q.hstAmt)}/mo`; totals += `Total Monthly${fmt(q.mrrWithHst)}/mo`; } if (waived && waivedAmt > 0) { totals += `Onboarding Fee WAIVED${q.contractTerm!=='m2m'?' — included with '+termLabel.split(' —')[0]:''}${fmt(waivedAmt)} saved`; } else if (onboardingFee > 0) { totals += `Onboarding Fee (one-time)${fmt(onboardingFee)}`; } totals += `Annual Projection${fmt(q.effectiveAnnual)}/yr`; // ── SVG logo (inline) ────────────────────────────────────────── const logo = ``; const html = ` SVS MSP Quote — ${client} ${logo} ${quoteRef} ${quoteDate} MSP Service Proposal Prepared for ${client} ${termLabel} · ${q.hstEnabled ? 'HST included in figures' : 'Prices in CAD, excl. HST'} Service Breakdown ${itemsHTML} Quote Summary ${totals}