// ── 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% // ── Nudge state — module-scoped (not window properties) ────────── let _nudges = []; let _nudgeIndex = 0; let _nudgeTimer; let _pricingFallbackShown = false; function showPricingStatus(message) { const host = document.querySelector('.top-bar-right'); if (!host) return; let el = document.getElementById('pricingStatus'); if (!el) { el = document.createElement('div'); el.id = 'pricingStatus'; el.style.marginTop = '6px'; el.style.fontSize = '11px'; el.style.letterSpacing = '0.02em'; el.style.color = 'var(--amber)'; host.appendChild(el); } el.textContent = message; } function reportPricingFallback(reason) { if (_pricingFallbackShown) return; _pricingFallbackShown = true; console.warn(`[SVS Quote] ${reason} Using built-in pricing defaults.`); showPricingStatus('Pricing CSV unavailable - using built-in defaults'); } // ── loadPricing() ──────────────────────────────────────────────── // Fetches package-prices.csv and overrides the pricing variables above. // Falls back to built-in defaults with a visible warning if CSV fails. async function loadPricing() { let appliedKeys = 0; try { const res = await fetch('package-prices.csv', { cache: 'no-store' }); if (!res.ok) { reportPricingFallback(`Could not load package-prices.csv (HTTP ${res.status}).`); return false; } const text = await res.text(); const lines = text.split('\n').slice(1); // skip header row lines.forEach(line => { const parts = line.split(','); if (parts.length < 3) return; const key = parts[1].trim(); const val = parseFloat(parts[2].trim()); if (isNaN(val)) return; let matched = true; switch (key) { case 'RATE_M365': RATE_M365 = val; break; case 'RATE_BYOL': RATE_BYOL = val; break; 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; default: matched = false; break; } if (matched) appliedKeys++; }); if (!appliedKeys) { reportPricingFallback('package-prices.csv loaded, but no recognized pricing keys were applied.'); return false; } return true; } catch (e) { reportPricingFallback(`Could not load package-prices.csv (${e?.message || 'request failed'}).`); return false; } } // --- 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 ? voipSeats * 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(); const m365BundleSavings = Math.max(0, RATE_M365 - RATE_BYOL); // ── Onboarding fee: auto = 50% MRR unless manually set or waived ── // 12-month and 24-month contracts auto-waive the onboarding fee. 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 (${fmt(ADMIN_FEE_FLOOR)}/mo)` : `$${Math.max(0, ADMIN_FEE_MINIMUM - baseSubtotal).toLocaleString()} more in services reduces admin fee further`; getEl('fb-base').textContent = fmt(siteAdminBase); getEl('fb-zt-row').classList.toggle('hidden', !ztActive); getEl('fb-zt').textContent = '+' + fmt(ADMIN_FEE_ZT); getEl('fb-pwm-row').classList.toggle('hidden', !addPWM); getEl('fb-pwm').textContent = '+' + fmt(admin1PWM); if (adminWaived) { 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); getEl('sl-users-sub')?.classList.toggle('hidden', users === 0); if (users > 0) { getEl('sl-users-val').textContent = fmt(userTotal); const sub = getEl('sl-users-sub'); sub.classList.remove('hidden'); const subParts = [`${users} × ${fmt(baseUserRate)}/user (${byol ? 'BYOL' : 'M365 Incl.'})`]; if (addExtHours) subParts.push(`+ ${fmt(userExt)}/mo ext. hrs`); if (addPWM) subParts.push(`+ ${fmt(userPWM)}/mo 1Password`); 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); getEl('sl-endpoints-sub')?.classList.toggle('hidden', endpoints === 0); if (endpoints > 0) { // endpointTotal includes serverBase — display endpoints-only so servers line doesn't double-count const epOnly = endpointTotal - serverBase; getEl('sl-endpoints-val').textContent = fmt(epOnly); const sub = getEl('sl-endpoints-sub'); sub.classList.remove('hidden'); const epParts = [`${endpoints} × ${fmt(RATE_ENDPOINT)}/endpoint`]; if (addBMB) epParts.push(`+ ${fmt(endpointBMB)}/mo Bare Metal Backup`); if (addUSB) epParts.push(`+ ${fmt(endpointUSB)}/mo USB Blocking`); sub.innerHTML = epParts.join('
'); } 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').classList.toggle('hidden', users === 0); if (users > 0) getEl('perUserDisplay').textContent = fmt(effectiveMrr / users) + '/user'; // Discount row (only shown when a term discount is active) 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'; } // 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 * m365BundleSavings); // BYOL callouts getEl('byolCalloutGreen').classList.toggle('hidden', byol); getEl('byolCalloutRed').classList.toggle('hidden', !byol); getEl('userIncluded').classList.toggle('byol-mode', byol); if (byol) getEl('byolRedSavings').textContent = fmt(users * m365BundleSavings); // 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 * ADDON_ZERO_TRUST_USER)}/mo for all ${users} user${users !== 1 ? 's' : ''}. Recommended for any client with remote staff or sensitive data.`, color: 'amber' }); if (!addPWM && users > 0) nudges.push({ text: `1Password Management — one vault for every credential, enforce strong passwords, revoke access instantly when staff leave. Only ${fmt(users * ADDON_1PASSWORD)}/mo for ${users} user${users !== 1 ? 's' : ''}.`, color: 'green' }); if (byol && users > 0) nudges.push({ text: `BYOL selected — switching to M365 Included (${fmt(RATE_M365)}/user) bundles the license and saves the client up to ${fmt(users * m365BundleSavings)}/mo vs retail Microsoft 365 pricing.`, color: 'green' }); if (endpoints > 0 && !addBMB) nudges.push({ text: `Bare Metal Backup protects against ransomware with image-level restore — ${fmt(endpoints * ADDON_BARE_METAL_BACKUP)}/mo covers all ${endpoints} endpoint${endpoints !== 1 ? 's' : ''}. Fast, bare-metal recovery if the worst happens.`, color: 'amber' }); if (voipSeats > 0 && voipTier === 'basic') nudges.push({ 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' }); _nudges = nudges; if (_nudgeIndex == null || _nudgeIndex >= nudges.length) _nudgeIndex = 0; renderNudge(); updateSavings(q); updateVsComparison(q); updateSectionSummaries(q); // Highlight addon preview pills when their add-on is selected document.querySelectorAll('.addon-preview-pill[data-addon]').forEach(pill => { const cb = document.getElementById(pill.dataset.addon); pill.classList.toggle('active', cb?.checked || false); }); debouncedSave(); } // ── onWaiveToggle() ────────────────────────────────────────────── // Called from onchange on #onboardingWaived checkbox. // Clears the manual override flag on the fee input so auto-calc resumes, // then runs update(). Extracted from inline HTML attribute for clarity. function onWaiveToggle() { const feeInput = document.getElementById('oneTimeFee'); if (feeInput) feeInput.removeAttribute('data-manual'); update(); } // ── toggleSection(id) ──────────────────────────────────────────── // Collapses/expands a numbered section card. // Adds/removes .sec-open on the section element. // .sec-open → chevron rotates 180deg (CSS), body shown (JS display). // Calls updateSectionSummaries() to show/hide summary badges. // Map: section ID → collapsible IDs that should auto-expand when section opens const _sectionCollapsibles = { 'sec-01': ['adminCovered'], 'sec-02': ['userIncluded', 'addonsA'], 'sec-03': ['endpointIncluded', 'addonsB'], 'sec-04': ['serverIncluded'], }; function toggleSection(id) { const section = document.getElementById(id); const body = document.getElementById(id + '-body'); if (!section || !body) return; const isOpen = section.classList.toggle('sec-open'); body.style.display = isOpen ? '' : 'none'; // Auto-expand inner collapsibles when section opens if (isOpen && _sectionCollapsibles[id]) { _sectionCollapsibles[id].forEach(cid => { const cBody = document.getElementById(cid); const cIcon = document.getElementById(cid + '-icon'); const cPreview = document.getElementById(cid + '-preview'); if (cBody && !cBody.classList.contains('open')) { cBody.classList.add('open'); if (cIcon) cIcon.classList.add('open'); if (cPreview) cPreview.style.display = 'none'; } }); } updateSectionSummaries(calcQuote()); updateToggleAllBtn(); } // ── 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 = ''; // Auto-expand inner collapsibles if (_sectionCollapsibles[id]) { _sectionCollapsibles[id].forEach(cid => { const cBody = document.getElementById(cid); const cIcon = document.getElementById(cid + '-icon'); const cPreview = document.getElementById(cid + '-preview'); if (cBody && !cBody.classList.contains('open')) { cBody.classList.add('open'); if (cIcon) cIcon.classList.add('open'); if (cPreview) cPreview.style.display = 'none'; } }); } } }); updateSectionSummaries(calcQuote()); updateToggleAllBtn(); } function updateToggleAllBtn() { const anyOpen = _allSecIds.some(id => document.getElementById(id)?.classList.contains('sec-open')); const btn = document.getElementById('toggleAllBtn'); if (!btn) return; const collapseSpan = btn.querySelector('.toggle-all-collapse-icon'); const expandSpan = btn.querySelector('.toggle-all-expand-icon'); const textSpan = btn.querySelector('.toggle-all-label'); if (collapseSpan) collapseSpan.style.display = anyOpen ? '' : 'none'; if (expandSpan) expandSpan.style.display = anyOpen ? 'none' : ''; if (textSpan) textSpan.textContent = anyOpen ? 'Collapse All' : 'Expand All'; } // ── stepCount(inputId, delta, event) ───────────────────────────── // 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) { if (!q) q = calcQuote(); // fallback only; always pass q explicitly from update()/toggleSection() const collapsed = id => !document.getElementById(id)?.classList.contains('sec-open'); const setSummary = (id, text) => { const el = document.getElementById(id); 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