Files
svsmspcalc/SVS-MSP-Calculator.js
2026-03-11 15:26:39 -04:00

1198 lines
63 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ── 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('<br>');
}
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('<br>');
}
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 <label class="addon-row"> 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);
getEl('vs-footnote').textContent = `Based on ~$85K Ottawa IT salary (2024) + min $650/mo 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`;
} 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; }
}
// ── exportQuote() ─────────────────────────────────────────────────
// Builds a plain-text quote summary from calcQuote() and triggers
// a browser file download: SVS_MSP_Quote_<ClientName>.txt
function exportQuote() {
const q = calcQuote();
const lines = [
'═══════════════════════════════════════════════════════',
' SVS MSP — Managed Services Quote',
'═══════════════════════════════════════════════════════',
` Client: ${q.clientName || '(not specified)'}`,
` Ref: ${document.getElementById('quoteRef')?.textContent || ''}`,
` Date: ${document.getElementById('headerDate')?.textContent || ''}`,
'───────────────────────────────────────────────────────',
'',
' SERVICES SUMMARY',
'',
];
if (q.users > 0) {
lines.push(` User Package (${q.users} users @ ${fmt(q.totalUserRate)}/user)`);
lines.push(` Base (${q.byol ? 'BYOL' : 'M365 Incl.'}): ${fmt(q.userBase)}/mo`);
if (q.userExt) lines.push(` Extended Hours: ${fmt(q.userExt)}/mo`);
if (q.userPWM) lines.push(` 1Password: ${fmt(q.userPWM)}/mo`);
if (q.userINKY) lines.push(` INKY Pro: ${fmt(q.userINKY)}/mo`);
if (q.userZT) lines.push(` Zero Trust: ${fmt(q.userZT)}/mo`);
lines.push(` Subtotal: ${fmt(q.userTotal)}/mo`);
lines.push('');
}
if (q.endpoints > 0 || q.servers > 0) {
lines.push(` Endpoint Package (${q.endpoints} endpoints @ $35)`);
if (q.endpointBMB) lines.push(` + Bare Metal Backup: ${fmt(q.endpointBMB)}/mo`);
if (q.endpointUSB) lines.push(` + USB Blocking: ${fmt(q.endpointUSB)}/mo`);
if (q.servers > 0) lines.push(` + ${q.servers} Server(s) @ $120: ${fmt(q.serverBase)}/mo`);
lines.push(` Subtotal: ${fmt(q.endpointTotal)}/mo`);
lines.push('');
}
lines.push(` Site Admin Fee: ${fmt(q.adminFeeNet)}/mo`);
lines.push('');
if (q.ztNetTotal > 0) {
lines.push(` Zero Trust Networking: ${fmt(q.ztNetTotal)}/mo`);
lines.push('');
}
if (q.voipTotal > 0) {
lines.push(` VoIP / UCaaS (${q.voipSeats} seats, ${q.voipTier}): ${fmt(q.voipTotal)}/mo`);
lines.push('');
}
lines.push('───────────────────────────────────────────────────────');
if (q.discountPct > 0) {
const termLabel = q.contractTerm === '12mo' ? '12-Month' : '24-Month';
lines.push(` BASE MRR: ${fmt(q.MRR)}`);
lines.push(` ${termLabel} DISCOUNT (${Math.round(q.discountPct*100)}%): ${fmt(q.discountAmt)}`);
}
lines.push(` MONTHLY RECURRING (MRR): ${fmt(q.effectiveMrr)}`);
if (q.hstEnabled) lines.push(` + HST (13%): ${fmt(q.hstAmt)}`);
lines.push(` ANNUAL PROJECTION: ${fmt(q.effectiveAnnual)}`);
if (q.oneTimeFee > 0) lines.push(` ONBOARDING FEE: ${fmt(q.oneTimeFee)} (one-time, not recurring)`);
if (q.users > 0) lines.push(` AVG. COST PER USER: ${fmt(q.effectiveMrr / q.users)}/user/mo (all services)`);
lines.push(` HST: ${q.hstEnabled ? 'Included in figures above' : 'Not included — HST applies at 13% on invoice'}`);
lines.push('───────────────────────────────────────────────────────');
lines.push('');
lines.push('Prepared by SVS MSP | This quote is valid for 30 days from date of issue.');
lines.push('Questions? Contact your SVS MSP account representative.');
const blob = new Blob([lines.join('\n')], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
const client = (q.clientName || 'Quote').replace(/[^a-z0-9]/gi,'_');
a.href = url;
a.download = `SVS_MSP_Quote_${client}.txt`;
a.click();
URL.revokeObjectURL(url);
}
// ── 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':''} × $${VOIP_RATES[q.voipTier]}/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 => `
<tr${r.sub?' class="sub"':''}>
<td class="lbl">${r.label}</td>
<td class="det">${r.detail}</td>
<td class="amt">${r.amt}/mo</td>
</tr>`).join('');
// ── Build totals ───────────────────────────────────────────────
let totals = '';
if (q.discountPct > 0) {
totals += `<tr class="t-muted"><td colspan="2">Base MRR</td><td>${fmt(q.MRR)}/mo</td></tr>`;
totals += `<tr class="t-muted"><td colspan="2">Term Discount (${Math.round(q.discountPct*100)}% off)</td><td>${fmt(q.discountAmt)}/mo</td></tr>`;
}
totals += `<tr class="t-mrr"><td colspan="2">Monthly Recurring (MRR)</td><td>${fmt(q.effectiveMrr)}/mo</td></tr>`;
if (q.hstEnabled) {
totals += `<tr class="t-muted"><td colspan="2">Ontario HST (13%)</td><td>+${fmt(q.hstAmt)}/mo</td></tr>`;
totals += `<tr class="t-total"><td colspan="2">Total Monthly</td><td>${fmt(q.mrrWithHst)}/mo</td></tr>`;
}
if (waived && waivedAmt > 0) {
totals += `<tr class="t-waived"><td colspan="2">Onboarding Fee <span class="badge">WAIVED</span>${q.contractTerm!=='m2m'?' — included with '+termLabel.split(' —')[0]:''}</td><td>${fmt(waivedAmt)} saved</td></tr>`;
} else if (onboardingFee > 0) {
totals += `<tr class="t-muted"><td colspan="2">Onboarding Fee (one-time)</td><td>${fmt(onboardingFee)}</td></tr>`;
}
totals += `<tr class="t-annual"><td colspan="2">Annual Projection</td><td>${fmt(q.effectiveAnnual)}/yr</td></tr>`;
// ── SVG logo (inline) ──────────────────────────────────────────
const logo = `<svg height="30" viewBox="0 0 424.27 97.38" xmlns="http://www.w3.org/2000/svg"><polyline points="7.32 8.88 62.11 8.88 34.72 58.22" fill="#1f75a6"/><polyline points="40.7 55.33 64.4 12.64 71.88 12.64 44.48 61.99 40.7 55.33" fill="#8d252f"/><path d="M110.03,89.91c-5.99,0-11.27-.66-15.86-1.99-4.59-1.33-8.6-3.2-12.05-5.63-3.45-2.42-6.54-5.27-9.27-8.53l14.21-15.92c3.79,4.85,7.75,8.05,11.88,9.61,4.13,1.55,8.01,2.33,11.65,2.33,1.44,0,2.73-.13,3.87-.4,1.14-.26,2.01-.7,2.62-1.31.61-.61.91-1.44.91-2.5,0-.98-.32-1.82-.97-2.5-.65-.68-1.5-1.27-2.56-1.76-1.06-.49-2.22-.91-3.47-1.25-1.25-.34-2.46-.62-3.64-.85-1.18-.23-2.22-.46-3.13-.68-4.55-1.06-8.53-2.35-11.94-3.87-3.41-1.51-6.25-3.33-8.53-5.46-2.27-2.12-3.96-4.55-5.06-7.28-1.1-2.73-1.65-5.8-1.65-9.21,0-3.87.89-7.39,2.67-10.57,1.78-3.18,4.17-5.91,7.16-8.19,2.99-2.27,6.4-4.02,10.23-5.23,3.83-1.21,7.79-1.82,11.88-1.82,5.99,0,10.99.55,15.01,1.65,4.02,1.1,7.39,2.67,10.12,4.72,2.73,2.05,5.08,4.43,7.05,7.16l-14.32,13.76c-1.67-1.59-3.41-2.9-5.23-3.92-1.82-1.02-3.7-1.78-5.63-2.27-1.93-.49-3.85-.74-5.74-.74-1.74,0-3.22.13-4.43.4-1.21.27-2.16.68-2.84,1.25-.68.57-1.02,1.35-1.02,2.33s.43,1.8,1.31,2.44c.87.64,1.97,1.19,3.3,1.65,1.32.46,2.65.82,3.98,1.08,1.33.27,2.44.47,3.35.62,4.17.76,8,1.8,11.48,3.13,3.49,1.33,6.54,3,9.15,5,2.61,2.01,4.62,4.51,6.03,7.5,1.4,3,2.1,6.54,2.1,10.63,0,5.84-1.46,10.73-4.38,14.67-2.92,3.94-6.84,6.92-11.77,8.92-4.93,2.01-10.42,3.01-16.48,3.01Z" fill="#1a1816"/><path d="M174.27,88.77l-31.72-79.58h24.56l12.73,34.79c.91,2.35,1.65,4.36,2.22,6.03.57,1.67,1.08,3.24,1.54,4.72.46,1.48.89,3.05,1.31,4.72.42,1.67.89,3.71,1.42,6.14h-3.98c.76-3.18,1.42-5.8,1.99-7.84.57-2.05,1.21-4.07,1.93-6.08.72-2.01,1.65-4.56,2.79-7.67l12.73-34.79h23.76l-31.95,79.58h-19.33Z" fill="#1a1816"/><path d="M257.94,89.91c-5.99,0-11.27-.66-15.86-1.99-4.59-1.33-8.6-3.2-12.05-5.63-3.45-2.42-6.54-5.27-9.27-8.53l14.21-15.92c3.79,4.85,7.75,8.05,11.88,9.61,4.13,1.55,8.02,2.33,11.65,2.33,1.44,0,2.73-.13,3.87-.4,1.14-.26,2.01-.7,2.62-1.31.61-.61.91-1.44.91-2.5,0-.98-.32-1.82-.97-2.5-.64-.68-1.5-1.27-2.56-1.76-1.06-.49-2.22-.91-3.47-1.25-1.25-.34-2.46-.62-3.64-.85-1.18-.23-2.22-.46-3.13-.68-4.55-1.06-8.53-2.35-11.94-3.87-3.41-1.51-6.25-3.33-8.53-5.46-2.27-2.12-3.96-4.55-5.06-7.28-1.1-2.73-1.65-5.8-1.65-9.21,0-3.87.89-7.39,2.67-10.57,1.78-3.18,4.17-5.91,7.16-8.19,2.99-2.27,6.4-4.02,10.23-5.23,3.83-1.21,7.79-1.82,11.88-1.82,5.99,0,10.99.55,15.01,1.65,4.02,1.1,7.39,2.67,10.12,4.72,2.73,2.05,5.08,4.43,7.05,7.16l-14.33,13.76c-1.67-1.59-3.41-2.9-5.23-3.92-1.82-1.02-3.69-1.78-5.63-2.27-1.93-.49-3.85-.74-5.74-.74-1.74,0-3.22.13-4.43.4-1.21.27-2.16.68-2.84,1.25-.68.57-1.02,1.35-1.02,2.33s.44,1.8,1.31,2.44c.87.64,1.97,1.19,3.3,1.65,1.33.46,2.65.82,3.98,1.08,1.33.27,2.44.47,3.35.62,4.17.76,8,1.8,11.48,3.13,3.49,1.33,6.54,3,9.15,5,2.62,2.01,4.62,4.51,6.03,7.5,1.4,3,2.1,6.54,2.1,10.63,0,5.84-1.46,10.73-4.38,14.67-2.92,3.94-6.84,6.92-11.77,8.92-4.93,2.01-10.42,3.01-16.48,3.01Z" fill="#1a1816"/><path d="M300.67,47.36V8.02h10.45l14.33,23.33-8.49-.06,14.5-23.27h10.12v39.35h-11.69v-9.39c0-3.37.08-6.41.25-9.11.17-2.7.46-5.38.87-8.04l1.35,3.54-9.5,14.73h-3.71l-9.33-14.73,1.41-3.54c.41,2.51.7,5.09.87,7.73.17,2.64.25,5.78.25,9.42v9.39h-11.69Z" fill="#1a1816"/><path d="M364.36,47.93c-2.96,0-5.57-.33-7.84-.98-2.27-.66-4.25-1.58-5.96-2.78-1.71-1.2-3.23-2.6-4.58-4.22l7.03-7.87c1.87,2.4,3.83,3.98,5.87,4.75,2.04.77,3.96,1.15,5.76,1.15.71,0,1.35-.07,1.91-.2.56-.13.99-.35,1.29-.65.3-.3.45-.71.45-1.24,0-.49-.16-.9-.48-1.24s-.74-.63-1.26-.87c-.53-.24-1.1-.45-1.71-.62-.62-.17-1.22-.31-1.8-.42-.58-.11-1.1-.22-1.55-.34-2.25-.52-4.22-1.16-5.9-1.91-1.69-.75-3.09-1.65-4.22-2.7-1.12-1.05-1.96-2.25-2.5-3.6-.54-1.35-.81-2.87-.81-4.55,0-1.91.44-3.65,1.32-5.23.88-1.57,2.06-2.92,3.54-4.05s3.17-1.99,5.06-2.58,3.85-.9,5.87-.9c2.96,0,5.43.27,7.42.81,1.99.54,3.65,1.32,5,2.33,1.35,1.01,2.51,2.19,3.49,3.54l-7.08,6.8c-.83-.79-1.69-1.43-2.59-1.94s-1.83-.88-2.78-1.12c-.96-.24-1.9-.37-2.84-.37-.86,0-1.59.07-2.19.2-.6.13-1.07.34-1.41.62-.34.28-.51.67-.51,1.15s.21.89.65,1.21c.43.32.97.59,1.63.82.66.22,1.31.4,1.97.53.66.13,1.21.23,1.66.31,2.06.38,3.95.89,5.68,1.55,1.72.66,3.23,1.48,4.53,2.47s2.29,2.23,2.98,3.71c.69,1.48,1.04,3.23,1.04,5.26,0,2.89-.72,5.3-2.16,7.25-1.44,1.95-3.38,3.42-5.82,4.41-2.44.99-5.15,1.49-8.15,1.49Z" fill="#1a1816"/><path d="M386.61,47.36V8.02h17.71c2.7,0,5.1.58,7.2,1.74,2.1,1.16,3.75,2.75,4.95,4.78,1.2,2.02,1.8,4.35,1.8,6.97s-.6,5.17-1.8,7.31c-1.2,2.14-2.85,3.81-4.95,5.03-2.1,1.22-4.5,1.83-7.2,1.83h-5.56v11.69h-12.14ZM398.53,25.33h3.54c.71,0,1.35-.14,1.91-.42.56-.28,1-.68,1.32-1.21.32-.52.48-1.18.48-1.97s-.16-1.42-.48-1.91c-.32-.49-.76-.85-1.32-1.1-.56-.24-1.2-.37-1.91-.37h-3.54v6.97Z" fill="#1a1816"/></svg>`;
const html = `<!DOCTYPE html>
<html lang="en"><head>
<meta charset="UTF-8">
<title>SVS MSP Quote — ${client}</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@400;600;700&family=DM+Mono:wght@400;500&display=swap');
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Poppins',Arial,sans-serif;font-size:13px;color:#1a1816;background:#fff;padding:52px 56px;max-width:860px;margin:0 auto}
/* ── Header ── */
.hdr{display:flex;justify-content:space-between;align-items:flex-start;padding-bottom:24px;border-bottom:2.5px solid #1a6a98;margin-bottom:32px}
.hdr-meta{text-align:right;font-family:'DM Mono',monospace;font-size:10.5px;color:#6b6360;letter-spacing:.07em;line-height:1.8}
.hdr-meta .ref{font-size:13px;font-weight:500;color:#1a1816;letter-spacing:.09em}
/* ── Client ── */
.client-lbl{font-family:'DM Mono',monospace;font-size:10px;letter-spacing:.15em;text-transform:uppercase;color:#6b6360;margin-bottom:4px}
.client-name{font-size:24px;font-weight:700;color:#1a1816;margin-bottom:6px}
.client-meta{font-family:'DM Mono',monospace;font-size:11px;color:#6b6360}
/* ── Section label ── */
.sec-lbl{font-family:'DM Mono',monospace;font-size:10px;letter-spacing:.15em;text-transform:uppercase;color:#6b6360;margin:28px 0 8px;padding-bottom:6px;border-bottom:1px solid #e8e4db}
/* ── Items table ── */
.items{width:100%;border-collapse:collapse}
.items td{padding:9px 8px;vertical-align:top;border-bottom:1px solid #f0ede8}
.items .lbl{color:#1a1816;width:50%}
.items .det{color:#6b6360;font-family:'DM Mono',monospace;font-size:11px;width:30%}
.items .amt{text-align:right;font-family:'DM Mono',monospace;font-size:12px;color:#1a6a98;width:20%;white-space:nowrap}
.items tr.sub td{padding-top:3px;padding-bottom:3px;font-size:12px;color:#6b6360}
.items tr.sub .amt{color:#8a9aaa}
/* ── Totals table ── */
.tots{width:100%;border-collapse:collapse;margin-top:4px}
.tots td{padding:8px 8px;border-bottom:1px solid #f0ede8;vertical-align:middle}
.tots td:last-child{text-align:right;font-family:'DM Mono',monospace;white-space:nowrap}
.t-muted td{color:#6b6360;font-size:12px}
.t-mrr td{font-weight:600;font-size:15px}
.t-mrr td:last-child{color:#1a6a98;font-size:16px}
.t-total td{font-weight:700;background:#f4f2ed;font-size:15px}
.t-total td:last-child{font-size:17px;color:#1a1816}
.t-waived td{color:#217045}
.t-waived td:last-child{font-family:'DM Mono',monospace}
.t-annual td{color:#6b6360;font-size:12px;border-bottom:none}
.badge{display:inline-block;background:#e8f7ef;color:#217045;font-size:9px;font-family:'DM Mono',monospace;letter-spacing:.1em;padding:1px 6px;border-radius:3px;margin-left:6px;vertical-align:middle}
/* ── Footer ── */
.footer{margin-top:48px;padding-top:18px;border-top:1px solid #e8e4db;display:flex;justify-content:space-between;align-items:flex-end}
.footer-left{font-family:'DM Mono',monospace;font-size:10px;color:#aaa;letter-spacing:.05em;line-height:1.9}
.footer-note{font-size:11px;color:#999;margin-top:8px;max-width:480px;line-height:1.6}
@media print{body{padding:0}@page{margin:1.5cm;size:A4}}
</style></head><body>
<div class="hdr">
${logo}
<div class="hdr-meta">
<div class="ref">${quoteRef}</div>
<div>${quoteDate}</div>
<div style="margin-top:4px;text-transform:uppercase;letter-spacing:.1em">MSP Service Proposal</div>
</div>
</div>
<div class="client-lbl">Prepared for</div>
<div class="client-name">${client}</div>
<div class="client-meta">${termLabel} &nbsp;·&nbsp; ${q.hstEnabled ? 'HST included in figures' : 'Prices in CAD, excl. HST'}</div>
<div class="sec-lbl">Service Breakdown</div>
<table class="items"><tbody>${itemsHTML}</tbody></table>
<div class="sec-lbl">Quote Summary</div>
<table class="tots"><tbody>${totals}</tbody></table>
<div class="footer">
<div>
<div class="footer-left">SILICON VALLEY SERVICES &nbsp;·&nbsp; OTTAWA, ON</div>
<div class="footer-note">This quote is valid for 30 days from the date above. All prices in CAD. HST at 13% applies on invoice unless already included. MRR is billed monthly in advance.</div>
</div>
<div style="font-family:'DM Mono',monospace;font-size:10px;color:#bbb;letter-spacing:.06em">${quoteDate}</div>
</div>
<script>window.addEventListener('load',()=>{window.print();})<\/script>
</body></html>`;
const w = window.open('', '_blank', 'width=900,height=700');
if (w) { w.document.write(html); w.document.close(); }
}
// ── exportQuoteJSON() ─────────────────────────────────────────────
// Export B — Structured JSON for tech team provisioning import.
// Triggers a .json file download AND puts the JSON on the clipboard.
// Field names match the tech provisioning checklist format.
function exportQuoteJSON() {
const q = calcQuote();
const ref = document.getElementById('quoteRef')?.textContent || '';
const dt = new Date();
const dateStr = `${dt.getFullYear()}-${String(dt.getMonth()+1).padStart(2,'0')}-${String(dt.getDate()).padStart(2,'0')}`;
const termMap = { 'm2m': 'Month-to-Month', '12mo': '12-Month', '24mo': '24-Month' };
const payload = {
quoteRef: ref,
quoteDate: dateStr,
clientName: q.clientName || '',
contractTerm: termMap[q.contractTerm] || 'Month-to-Month',
licensing: q.byol ? 'BYOL' : 'M365-Included',
users: q.users,
endpoints: q.endpoints,
servers: q.servers,
addons: {
extendedHours: q.addExtHours,
passwordManager: q.addPWM,
inkyPro: q.addINKY,
zeroTrustUsers: q.addZT,
baremetalBackup: q.addBMB,
usbBlocking: q.addUSB
},
zeroTrustNetwork: {
seats: q.ztSeats,
routers: q.ztRouters
},
voip: {
tier: q.voipSeats > 0 ? q.voipTier : null,
seats: q.voipSeats,
phoneHardware: q.addVoipPhone,
faxLine: q.addVoipFax
},
pricing: {
baseMrr: q.MRR,
discountPct: Math.round(q.discountPct * 100),
discountAmt: q.discountAmt,
effectiveMrr: q.effectiveMrr,
annual: q.effectiveAnnual,
oneTimeFee: q.oneTimeFee,
hstIncluded: q.hstEnabled,
hstAmt: q.hstAmt,
mrrWithHst: q.mrrWithHst
}
};
const json = JSON.stringify(payload, null, 2);
const client = (q.clientName || 'Quote').replace(/[^a-z0-9]/gi,'_');
// Download
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `SVS_MSP_Quote_${client}.json`;
a.click();
URL.revokeObjectURL(url);
// Clipboard
navigator.clipboard?.writeText(json).then(() => {
const btn = document.getElementById('btnExportJSON');
if (btn) {
const orig = btn.textContent;
btn.textContent = '✓ Copied to clipboard';
setTimeout(() => { btn.textContent = orig; }, 2000);
}
}).catch(() => {});
}
// ── THEME TOGGLE ─────────────────────────────────────────────────
// Light theme is a separate CSS file imported dynamically on demand.
// Dark mode = base stylesheet only (no extra link element).
// Light mode = base + SVS-MSP-Calculator-light.css.
// Preference persisted to localStorage under 'svs-theme'.
// initTheme() called first so the page never flashes the wrong theme.
const SVG_SUN = '<svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>';
const SVG_MOON = '<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>';
function toggleTheme() {
const link = document.getElementById('lightThemeLink');
const icon = document.getElementById('themeToggleIcon');
const btn = document.getElementById('themeToggle');
if (link) {
// Currently light → switch to dark
link.remove();
localStorage.setItem('svs-theme', 'dark');
if (icon) icon.innerHTML = SVG_SUN;
if (btn) btn.setAttribute('title', 'Switch to light theme');
} else {
// Currently dark → switch to light
const el = document.createElement('link');
el.id = 'lightThemeLink';
el.rel = 'stylesheet';
el.href = 'SVS-MSP-Calculator-light.css';
document.head.appendChild(el);
localStorage.setItem('svs-theme', 'light');
if (icon) icon.innerHTML = SVG_MOON;
if (btn) btn.setAttribute('title', 'Switch to dark theme');
}
}
function initTheme() {
if (localStorage.getItem('svs-theme') === 'light') {
const el = document.createElement('link');
el.id = 'lightThemeLink';
el.rel = 'stylesheet';
el.href = 'SVS-MSP-Calculator-light.css';
document.head.appendChild(el);
const icon = document.getElementById('themeToggleIcon');
const btn = document.getElementById('themeToggle');
if (icon) icon.innerHTML = SVG_MOON;
if (btn) btn.setAttribute('title', 'Switch to dark theme');
}
}
// ── initQuote() ──────────────────────────────────────────────────
// Entry point. Sets quote ref (SVS-YYYYMMDD-XXXX) and date,
// restores saved state, then calls update() for initial render.
// Called once at bottom of script.
function initQuote() {
const now = new Date();
const months = ['JANUARY','FEBRUARY','MARCH','APRIL','MAY','JUNE','JULY','AUGUST','SEPTEMBER','OCTOBER','NOVEMBER','DECEMBER'];
const year = now.getFullYear();
const month = months[now.getMonth()];
const dateStr = `${year}${String(now.getMonth()+1).padStart(2,'0')}${String(now.getDate()).padStart(2,'0')}`;
const savedRef = localStorage.getItem('svs-msp-quote-ref');
const quoteRef = savedRef || `SVS-${dateStr}-${String(Math.floor(Math.random()*9000)+1000)}`;
if (!savedRef) localStorage.setItem('svs-msp-quote-ref', quoteRef);
document.getElementById('quoteRef').textContent = quoteRef;
document.getElementById('headerDate').textContent = `${month} ${year}`;
restoreState();
update();
}
initTheme();
initQuote();
// ── MOBILE QUOTE PANEL IIFE ──────────────────────────────────────
// Encapsulates all mobile panel logic to avoid polluting global scope.
// ARCHITECTURE:
// The real sidebar lives in .side-col (desktop).
// The panel contains a STATIC DUPLICATE with _m suffixed IDs.
// update() is wrapped here to sync _m elements after every update.
// openMobilePanel / closeMobilePanel are exposed on window.
// Do NOT DOM-move the real sidebar into the panel —
// it permanently breaks desktop layout on resize.
(function() {
// Panel uses a static duplicate sidebar (_m IDs) — no DOM moving needed.
window.openMobilePanel = function() {
var panel = document.getElementById('mobileQuotePanel');
if (panel) {
panel.classList.add('open');
document.body.style.overflow = 'hidden';
}
};
window.closeMobilePanel = function() {
var panel = document.getElementById('mobileQuotePanel');
if (panel) {
panel.classList.remove('open');
document.body.style.overflow = '';
}
};
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') window.closeMobilePanel();
});
window.addEventListener('resize', function() {
if (window.innerWidth > 1100) window.closeMobilePanel();
});
// ── MOBILE PANEL SYNC ────────────────────────────────────────
// syncEl(id) — copies innerHTML from #id to #id_m
// syncClass(id) — copies className from #id to #id_m (hidden/shown state)
// syncStyle(id) — copies style.cssText from #id to #id_m (inline display)
function syncEl(id) {
var src = document.getElementById(id);
var dst = document.getElementById(id + '_m');
if (src && dst) dst.innerHTML = src.innerHTML;
}
function syncClass(id) {
var src = document.getElementById(id);
var dst = document.getElementById(id + '_m');
if (src && dst) dst.className = src.className;
}
function syncStyle(id) {
var src = document.getElementById(id);
var dst = document.getElementById(id + '_m');
if (src && dst) dst.style.cssText = src.style.cssText;
}
// ── UPDATE WRAPPER ─────────────────────────────────────────────
// Wraps the global update() to also sync the mobile panel.
// _origUpdate = the real update() defined above.
// After _origUpdate() runs, all _m IDs are synced.
// Do not call _origUpdate() directly anywhere else.
var _origUpdate = window.update;
window.update = function() {
_origUpdate();
// Sync all mirrored sidebar elements
syncEl('clientNameDisplay');
syncEl('sl-users-val');
syncEl('sl-endpoints-val');
syncEl('sl-servers-val');
syncEl('sl-zt-val');
syncEl('sl-voip-val');
syncEl('sl-admin-val');
syncEl('mrrDisplay');
syncEl('annualDisplay');
syncEl('perUserDisplay');
syncEl('perUserBreakdown');
syncEl('adminPct');
syncEl('m365SaveAmt');
syncEl('sl-discount-val');
syncEl('sl-base-mrr-val');
syncEl('sl-hst-val');
syncEl('sl-hst-total-val');
syncEl('sl-otf-val');
syncEl('vs-svs-annual');
syncEl('vs-1man-cost');
syncEl('vs-1man-save');
syncEl('vs-1man-save-lbl');
syncEl('vs-5man-cost');
syncEl('vs-5man-save');
syncEl('vs-5man-save-lbl');
syncEl('vs-footnote');
syncEl('nudgeText');
syncEl('nudgeCounter');
syncEl('sl-users-sub');
syncEl('sl-endpoints-sub');
syncClass('sl-users');
syncClass('sl-users-sub');
syncClass('sl-endpoints');
syncClass('sl-endpoints-sub');
syncClass('sl-servers');
syncClass('sl-zt');
syncClass('sl-voip');
syncClass('sl-admin');
syncClass('sideNote-admin');
syncClass('sideNote-m365');
syncClass('sideNote-byol');
syncClass('vsComparison');
syncClass('perUserRow');
syncClass('perUserBreakdown');
syncClass('sl-discount-row');
syncClass('sl-base-mrr-row');
syncClass('sl-hst-row');
syncClass('sl-hst-total-row');
syncClass('sl-otf-row');
syncStyle('sl-discount-row');
syncStyle('sl-hst-row');
syncStyle('sl-otf-row');
syncClass('vs-1man-save-row');
syncClass('vs-1man-save');
syncClass('vs-1man-save-lbl');
syncClass('vs-5man-save-row');
syncClass('vs-5man-save');
syncClass('vs-5man-save-lbl');
syncClass('nudgeBanner');
syncStyle('sl-users-sub');
syncStyle('sl-endpoints-sub');
syncStyle('perUserRow');
// Pill MRR — show effective MRR with label
var mrr = document.getElementById('mrrDisplay');
var pill = document.getElementById('mobilePillMrr');
if (mrr && pill) pill.textContent = mrr.textContent;
};
// Run one full sync now to populate panel on initial load
window.update();
})();